Compare commits

...
52 Commits
Author SHA1 Message Date
Nimar 4a13377e35 chore: release v3.167.2 2026-04-10 15:40:12 +02:00
NimarandGitHub 30af822ac9 chore(deps): bump defu (#13100) 2026-04-10 13:32:54 +00:00
NimarandGitHub c2c0b661e7 chore(deps): bump hono to 4.12.12 (#13099) 2026-04-10 13:22:12 +00:00
Hassieb PakzadandGitHub 2e94ebfe4b fix(evals): prevent llm-as-a-judge queue stalls (#13037) 2026-04-10 14:11:59 +02:00
NimarandGitHub b8544b3423 chore(deps): bump next to 16.2.3 (#13092) 2026-04-10 10:20:59 +00:00
NimarandGitHub 24cc309fb8 chore(deps): bump lodash 4.18.1 (#13090) 2026-04-10 09:44:35 +00:00
NimarandGitHub 1ca70d7033 chore(deps): bump langchain 1.1.39 and related (#13089) 2026-04-10 09:33:32 +00:00
NimarandGitHub ba980c302e chore(deps): bump slack and thus axios 1.15.0 (#13088) 2026-04-10 09:26:48 +00:00
Hassieb PakzadandGitHub ea197e4287 fix(llm-execution-tracing): imperatively set internal tracing environment on events (#13085) 2026-04-10 11:28:48 +02:00
NimarandGitHub 0b20e4d366 chore(deps): build go migrate with clickhouse only (#13082)
* chore(deps): build go migrate with clickhouse only

* add comment
2026-04-10 09:21:55 +00:00
NimarandGitHub 31a1a34616 chore(deps): bump node mocks to 1.17.2 (#13087) 2026-04-10 09:16:59 +00:00
Valery MeleshkinandGitHub 3c3d4bf129 chore: add scripts to provision and run local cloud dependencies (Postgres, Redis, ClickHouse, MinIO) and setup/maintenance helpers (#13054) 2026-04-10 11:19:24 +02:00
07cae52cc7 fix: validate Azure blob storage container names (#13080)
* fix: validate Azure blob storage container names

Azure requires container names to be 3-63 chars, lowercase alphanumeric
and hyphens only. Add Zod superRefine validation to the form schema,
tRPC router, and public API schema so invalid names like "Feedback N8N Bot"
are rejected at submission time with a clear error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback for Azure container name validation

- Add empty-string guard in validateAzureContainerName to avoid double
  error when bucketName is blank
- Add .min(1) to public API bucketName schema to match tRPC form schema
- Add Fern docs note describing Azure container naming constraints
- Add server test for invalid Azure container name rejection
- Add client test for empty-string guard behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:11:04 +00:00
Ben BachemandGitHub a81edec0be fix(scores-table): Unused omittedFilter prop (#13079) 2026-04-10 09:07:16 +00:00
Ben BachemandGitHub 497179934d fix(web): Table padding issues (#13060)
* fix(web): Table padding issues

* Increase cell padding in `SelectDashboardDialog` and `SelectWidgetDialog`

* Fix memoization comparison for cellPadding in DataTable

* Set cellPadding="comfortable" for `MembersTable` in org settings
2026-04-10 09:07:08 +00:00
NimarandGitHub ad9dfc41a2 chore(deps): bump vitest to 4.1.4 (#13086) 2026-04-10 09:03:48 +00:00
Tobias Wochinger 9cc69f4c67 chore: release v3.167.1 2026-04-10 10:29:22 +02:00
557f284cd1 fix(web): allow all unicode letters for signups (#12999)
* fix(web): allow unicode letters in signup name validation

* refactor(web): share name schema between signup and display name

* fix(web): enforce 100-char limit in shared name schema

* fix(web): allow hyphens, apostrophes, and periods in name validation

The nameSchema regex was too strict, rejecting common name characters
like O'Brien, Smith-Jones, and Dr. Smith. Also align the backend
updateDisplayName schema with the shared nameSchema for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): normalize smart quotes and require letter in name validation

Normalize curly/smart apostrophes (U+2018, U+2019, U+02BC) from mobile
autocorrect to straight apostrophe before validation. Require at least
one letter to reject degenerate punctuation-only names like "---".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): require base letter not combining mark in name validation

The "must contain at least one letter" refine accepted standalone
combining marks (\p{M}) without an actual letter (\p{L}), allowing
inputs like "\u0301\u0301" to pass as valid names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): require base letter not combining mark in name validation

Add NFC normalization before validation so decomposed characters merge
into precomposed form, and add a negative lookahead (?!\p{M}) to reject
names that still start with a combining mark after normalization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): revert display name form to permissive schema and simplify nameSchema

Revert settings and userAccount display name validation back to
StringNoHTML.min(1).max(100) — the signup-oriented nameSchema is too
restrictive for existing display names containing underscores, ampersands, etc.

Simplify nameSchema: merge transforms, combine regex constraints into a single
refine that requires names start with a letter, and remove U+02BC from
smart-quote normalization (it's a linguistic letter, not a typographic quote).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:35:41 +00:00
NimarandGitHub 6702c7b50f chore(deps): bump lodash to 4.18.1 in worker (#13063)
* chore(deps): update package wait to 5days

* remove superfluous

* chore(deps): bump lodash to 4.18.1 in worker
2026-04-09 16:57:03 +00:00
25d99aa371 fix: make Slack integration more robust (#13004)
* fix: make Slack integration more robust

* refactor: deduplicate scopes

* fix(slack): make SlackChannel isPrivate and isMember optional

These fields are only known for channels from the fetched list, not for
manually-typed channel names. Making them optional avoids placeholder
booleans and fixes a type error when constructing partial channel objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: gracefully handle missing scopes

* fix(slack): cap rate-limit retry, resolve manual channel IDs, add empty state

- Cap retryAfter to 60s max to avoid gateway timeouts on large Slack values
- Add onSuccess handler in SlackActionForm to resolve #channel names to real IDs
- Show empty state message in ChannelSelector when bot has no accessible channels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(slack): resolve manual channel IDs, virtualize list, fix audit log

- Use resolved Slack channel ID in audit log instead of #-prefixed input
- Replace VirtualizedList with cmdk Command + @tanstack/react-virtual
  for keyboard navigation and DOM-efficient rendering of ~5k channels
- Move "Use typed name" fallback to separate CommandGroup so it stays
  visible when the virtualized group has zero height
- Import SlackChannel type from @langfuse/shared instead of redeclaring
- Add getChannelInfo mock and #-prefixed channelId test
- Use .concat() instead of spread for channel pagination (repo convention)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: switch to SDK retry policies

* fix(slack): strip duplicate # prefix and use functional setState

Strip leading # from channelId fallback in test message block to avoid
displaying ##general for manually-typed channel names. Use functional
setSelectedChannel form in slack.tsx to match SlackActionForm.tsx pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(slack): guard CommandEmpty on filteredChannels length

Prevent flash of "No channels available." on popover open by explicitly
guarding CommandEmpty rendering on filteredChannels.length === 0 instead
of relying on cmdk's internal item count, which is 0 on the first
render before the virtualizer scroll container mounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: review comments

* chore: another round of review feedback

* chore: more review comments

* fix(slack): improve channel selector search

* fix comment

* fix(slack): refine channel selector search

* fix(slack): sync manifest scopes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:46:36 +00:00
NimarandGitHub e4d5f914cc chore(deps): bump next to 16.2.2 (#13068)
* chore(deps): bump next to 16.2.2

* bump big
2026-04-09 18:26:23 +02:00
Hassieb PakzadandGitHub dcb5dbf528 fix(llm-connections): validate new LLM base URLs (#13073) 2026-04-09 17:25:59 +02:00
Valery MeleshkinandGitHub d003a9c3f4 fix: limt media deletion batch size to avoid pg bind limits (#13072) 2026-04-09 16:29:25 +02:00
Valery MeleshkinandGitHub d2d56f0337 fix: allow Bearer auth on POST scores API (#13064)
fix: allow Bearer auth on POST scores API.

Addresses https://github.com/langfuse/langfuse/issues/12947
2026-04-09 13:45:29 +00:00
NimarandGitHub 6c0cf07a5a chore(deps): update package wait to 5days (#13062)
* chore(deps): update package wait to 5days

* remove superfluous
2026-04-09 12:39:11 +00:00
Hassieb Pakzad dd632fea9e chore: release v3.167.0 2026-04-09 14:26:32 +02:00
Hassieb PakzadandGitHub 7527bb0d84 fix(web): require secret key for LLM test base URL changes (#13055) 2026-04-09 14:25:31 +02:00
NimarandGitHub 8cc4a5537f fix(cicd): re-add nextauth etc to docker (#12865)
* fix(cicd): re-add nextauth etc to docker

* clarify

* fix prisma version

* one more comment
2026-04-09 12:09:01 +00:00
marliessophieandGitHub 0a6d3f108a chore(experiments): Link dataset cell in Experiments table to dataset page and show display name (#13059)
fix(experiments): render dataset badge label without table-id component
2026-04-09 12:05:11 +00:00
Ben BachemandGitHub 1bf83313e3 fix(trace-table): Only disable URL persistence for ScoresTable in peek mode (#12963)
fix(web): Only disable URL persistence for `ScoresTable` in peek mode
2026-04-09 11:52:40 +00:00
marliessophieandGitHub 9c3a715d77 fix(annotation): Wait for session to load before rendering annotation queue items (#13058)
fix(web): simplify annotation queue loading state guard
2026-04-09 11:32:58 +00:00
marliessophieandGitHub 0cf2a33473 fix(v4-add-to-dataset): Allow non-string JSON prefill values for new dataset items (#13053)
* fix(datasets): normalize add-to-dataset prefill values

* fix(datasets): preserve parsed null prefill values
2026-04-09 11:11:09 +00:00
Valery MeleshkinandGitHub 51554eb066 chore(dx): add blob storage docs review checks to AGENTS.md (#13056) 2026-04-09 12:24:47 +02:00
marliessophieandGitHub cbc21bb9cc feat(annotation-queues): integrate session handling and beta feature flag in AnnotationQueueItemPage and update router for observation fetching (#13050) 2026-04-09 09:44:50 +00:00
Valery MeleshkinandGitHub 8e30694214 Revert "chore: optional docker setup in Codex setup/maintenance scripts" (#13049)
Revert "chore: optional docker setup in Codex setup/maintenance scripts (#13035)"

This reverts commit 0d20d9de2b.
2026-04-09 10:38:13 +02:00
Valery MeleshkinandGitHub 0d20d9de2b chore: optional docker setup in Codex setup/maintenance scripts (#13035)
* fix(codex): install golang-migrate in docker setup

* fix(codex): include local bin path in maintenance

* fix(codex): load local bin path in setup shell

* fix(codex): verify migrate checksum and safe extract

* fix(codex): improve migrate install error guidance
2026-04-08 17:03:11 +00:00
Tobias Wochinger eeeba25439 chore: release v3.166.0 2026-04-08 19:03:32 +02:00
a59630d656 chore(dx): add more steps for pre-commit (#12901)
* chore(dx): add more stop for pre-commit

* chore: add type checking as well

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add auto-fixed files to diff

* chore: change to not modifying / remove typecheck

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:04:41 +00:00
Valery MeleshkinandGitHub 10e7dea9c9 fix: getTraceById metadata (#13042) 2026-04-08 17:35:07 +02:00
marliessophieandGitHub 9bf326db4f Revert "fix(dataset-items): update current dataset item versions in upsert function" (#13043)
Revert "fix(dataset-items): update current dataset item versions in upsert fu…"

This reverts commit c393c64a40.
2026-04-08 17:26:31 +02:00
13190c3ec4 perf(trace-ui): Exclude and tool columns from trace observations query when IO is not requested (#12948)
* put prompt_* and tool_* behind 'includeIO'

* ordering

* put prompt outside of io

* also omit for events

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-04-08 15:05:50 +00:00
Ben BachemandGitHub 7a4ce9ee5d fix(web): Inconsistent search results between editor and controller (#13038) 2026-04-08 15:05:37 +00:00
NimarandGitHub bc02989ccf fix(ui): remove right screen side handle on mobile (#13036) 2026-04-08 17:02:55 +02:00
NimarandGitHub f0dac0299c chore(deps): bump turbo to 2.9.5 (#13032) 2026-04-08 14:09:50 +00:00
Valery MeleshkinandGitHub 19997064c1 feat(api): add fields parameter to GetTraceById endpoint (#13015) 2026-04-08 13:58:34 +00:00
marliessophieandGitHub c393c64a40 fix(dataset-items): update current dataset item versions in upsert function (#13034) 2026-04-08 13:42:47 +00:00
d04e027107 fix(prompt-automations): prompt creations no longer trigger webhooks for unfiltered event actions (#13000)
* fix(prompt): webhook triggers honor eventAction filters

* fix(automations): added validation of event actions

* test(automations): add deleted event action test case to promptVersionProcessor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Revert "fix(automations): added validation of event actions"

This reverts commit 210344f49b45fd80e79fecf6841dd37dbe822a28.

* fix(test): update setupTriggerAndAction to match all event actions

The helper used eventActions: ["updated"] which broke the prompt
creation test after eventActions filtering was enforced. Using []
matches all actions, covering both created and updated test cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ci): retrigger stuck license cla check

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:21:03 +00:00
marliessophieandGitHub 53869c7200 fix(dataset-items): handle version conflict errors in dataset item upsert (#13031) 2026-04-08 12:49:20 +00:00
6d964894fb fix(api): return archived item when getting dataset item by ID (#13028)
fix(api): return archived dataset items from GET endpoint

Previously GET /api/public/dataset-items/{id} returned 404 for archived
items. Now it returns them with their status, matching user expectations
for direct ID lookups.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:24:44 +00:00
81653b2bd2 ci: add cla-assistant workflow to retrigger stuck CLA checks (#13021)
Workaround for a known cla-assistant bug where the CLA check gets stuck
after a contributor signs. Comment `/check-cla` on any PR to manually
retrigger. See: https://github.com/cla-assistant/cla-assistant/issues/528

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:02:08 +00:00
marliessophieandGitHub 02d6486f10 chore(dataset-items): patch API on version collision (#13029)
* chore(dataset-items): patch API to throw 4xx instead on version collision

* chore: push
2026-04-08 11:46:38 +00:00
07ee4ed961 fix(web): Improve search highlighting in CodeMirrorEditor (#12961)
* fix(web): Improve search highlighting in CodeMirrorEditor

* Add color variables

* Always call `syncEditorsToQuery` if the active changed

* make selector more spefific

---------

Co-authored-by: Nimar <l.nimar.b@gmail.com>
2026-04-08 11:24:22 +00:00
119 changed files with 5918 additions and 2015 deletions
+12 -3
View File
@@ -1,10 +1,19 @@
# Dev container Dockerfile
FROM --platform=${BUILDPLATFORM} golang:1.24 AS migrate-builder
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 \
GOBIN=/out \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
# Build only the ClickHouse migrate CLI used in this repo.
RUN /usr/local/go/bin/go install -trimpath -tags 'clickhouse' -ldflags='-s -w' \
github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
FROM mcr.microsoft.com/devcontainers/universal:2
# Install golang-migrate for database migrations
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz && \
chmod +x migrate && \
mv migrate /usr/local/bin/migrate
COPY --from=migrate-builder /out/migrate /usr/local/bin/migrate
# Activate the repo's pinned pnpm via Corepack
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
+5
View File
@@ -145,6 +145,11 @@ LANGFUSE_AI_FEATURES_SECRET_KEY="sk-lf-1234567890"
LANGFUSE_AI_FEATURES_HOST="http://localhost:3000"
LANGFUSE_AI_FEATURES_PROJECT_ID=7a88fb47-b4e2-43b8-a06c-a5ce950dc53a
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=localhost
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=127.0.0.1,::1
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=127.0.0.0/8
# Langfuse AI Bedrock credentials
AWS_ACCESS_KEY_ID="A123456789"
AWS_SECRET_ACCESS_KEY="SAK123456789"
+7
View File
@@ -235,6 +235,9 @@ LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
# Comma-separated default field groups for GET /api/public/traces when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACES_DEFAULT_FIELDS=
# Comma-separated default field groups for GET /api/public/traces/{traceId} when no fields param is provided
# Valid values: core, io, scores, observations, metrics
# LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS=
### START Enterprise Edition Configuration
@@ -296,6 +299,10 @@ LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
# Admin API
# ADMIN_API_KEY=
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=
# LANGFUSE_CACHE_MODEL_MATCH_ENABLED=
# LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS=
+1 -1
View File
@@ -12,7 +12,7 @@ updates:
schedule:
interval: "daily"
cooldown:
default-days: 8
default-days: 5
versioning-strategy: "increase"
commit-message:
prefix: chore
+20
View File
@@ -0,0 +1,20 @@
# Workaround for a known cla-assistant bug where the CLA check gets stuck and
# never re-runs after a contributor signs the CLA.
# See: https://github.com/cla-assistant/cla-assistant/issues/528
#
# Usage: comment `/check-cla` on any PR to manually retrigger the CLA check.
name: CLA Assistant
on:
issue_comment:
types: [created]
jobs:
retrigger_cla:
# Only run on PR comments (not issue comments) with the /check-cla command
if: github.event.issue.pull_request && github.event.comment.body == '/check-cla'
runs-on: ubuntu-latest
steps:
- name: Retrigger CLA check
run: |
curl -s "https://cla-assistant.io/check/langfuse/langfuse?pullRequest=${{ github.event.issue.number }}"
+4 -2
View File
@@ -11,13 +11,15 @@ if [ "$current_branch" = "$protected_branch" ]; then
echo "🚨 You are about to commit to the $protected_branch branch. Are you sure? (y/n)"
read -r answer < /dev/tty
if [ "$answer" != "${answer#[Yy]}" ]; then
# Commit approved, check formatting. On files changed, block commit
# Commit approved, run checks
pnpm run format:check
pnpm run lint
else
echo "Commit to $protected_branch branch has been canceled."
exit 1 # Commit will be blocked
fi
fi
# If not the protected branch, check formatting (on changed files, block)
# If not the protected branch, run checks
pnpm run format:check
pnpm run lint
+44 -39
View File
@@ -418,45 +418,48 @@ The background color of the following component will be `hsl(var(--primary))` an
### Color Variables
| Variable | Description | Examples |
| ------------------------ | ------------------------------------------------------------------ | -------------------------------- |
| --background | Background color | Default background color of body |
| --foreground | Foreground color | Default text color of body |
| --muted | Muted background color | TabsList, Skeleton and Switch |
| --muted-foreground | Muted foreground color | |
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
| --popover-foreground | Popover foreground color | |
| --card | Card background color | Card |
| --card-foreground | Card foreground color | |
| --border | Border color | Default border color |
| --input | Input field border color | Input, Select, Textarea |
| --primary | Primary button background colors | Button variant="primary" |
| --primary-foreground | Primary button foreground color | |
| --secondary | Secondary button background color | Button variant="secondary" |
| --secondary-foreground | Secondary button foreground color | |
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
| --destructive | Destructive action color for background | Button variant="destructive" |
| --destructive-foreground | Destructive action color for text | |
| --ring | Focus ring color | MultiSelect |
| --primary-accent | Primary accent color used for branding | Layout |
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
| --muted-green | Muted green for Event label | ObservationTree |
| --muted-magenta | Muted magenta for Generation label | ObservationTree |
| --muted-blue | Muted blue for Span label | ObservationTree |
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
| --light-red | Light red for error background | level-color and StatusBadge |
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
| --light-yellow | Light yellow for warning background | LevelColor |
| --dark-yellow | Dark yellow for warning text | LevelColor |
| --light-green | Light green for success status badge background | StatusBadge |
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
| Variable | Description | Examples |
| -------------------------------- | ------------------------------------------------------------------ | -------------------------------- |
| --background | Background color | Default background color of body |
| --foreground | Foreground color | Default text color of body |
| --muted | Muted background color | TabsList, Skeleton and Switch |
| --muted-foreground | Muted foreground color | |
| --popover | Popover background color | DropdownMenu, HoverCard, Popover |
| --popover-foreground | Popover foreground color | |
| --card | Card background color | Card |
| --card-foreground | Card foreground color | |
| --border | Border color | Default border color |
| --input | Input field border color | Input, Select, Textarea |
| --primary | Primary button background colors | Button variant="primary" |
| --primary-foreground | Primary button foreground color | |
| --secondary | Secondary button background color | Button variant="secondary" |
| --secondary-foreground | Secondary button foreground color | |
| --accent | Used for accents such as hover effects | DropdownMenuItem, SelectItem |
| --accent-foreground | Used for texts on hover effects | DropdownMenuItem, SelectItem |
| --destructive | Destructive action color for background | Button variant="destructive" |
| --destructive-foreground | Destructive action color for text | |
| --ring | Focus ring color | MultiSelect |
| --primary-accent | Primary accent color used for branding | Layout |
| --hover-primary-accent | Primary accent color used for hover effects for links | SignIn and AuthCloudRegionSwitch |
| --muted-green | Muted green for Event label | ObservationTree |
| --muted-magenta | Muted magenta for Generation label | ObservationTree |
| --muted-blue | Muted blue for Span label | ObservationTree |
| --muted-gray | Muted gray for disabled status badges | StatusBadge |
| --accent-light-green | Light green accent for background of output and assistant messages | IOPreview, Generations, Traces |
| --accent-dark-green | Dark green accent for border of output and assistant messages | CodeJsonViewer and IOPReview |
| --light-red | Light red for error background | level-color and StatusBadge |
| --dark-red | Dark red for error text and error badge dot color | level-color and ErrorPage |
| --light-yellow | Light yellow for warning background | LevelColor |
| --dark-yellow | Dark yellow for warning text | LevelColor |
| --light-green | Light green for success status badge background | StatusBadge |
| --dark-green | Dark green for success status badge text and dot | StatusBadge |
| --light-blue | Light blue for background of Staging label | LangfuseLogo |
| --dark-blue | Dark blue for text and border of Staging label | LangfuseLogo |
| --accent-light-blue | Light blue accent for table link hover effect | TableLink |
| --accent-dark-blue | Dark blue accent for table link text | TableLink |
| --find-match-selected-background | Background color for selected search matches | CodeMirrorEditor |
| --find-match-selected-foreground | Foreground color for selected search matches | CodeMirrorEditor |
| --find-match-background | Background color for search matches | CodeMirrorEditor |
### Adding New Colors
@@ -510,3 +513,5 @@ npx fern-api generate --api organizations # for the organizations API
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
When contributing to the Langfuse codebase, you need to agree to the [Contributor License Agreement](https://cla-assistant.io/langfuse/langfuse). You only need to do this once and the CLA bot will remind you if you haven't signed it yet.
If the CLA check gets stuck after signing (a [known cla-assistant bug](https://github.com/cla-assistant/cla-assistant/issues/520)), comment `/check-cla` on your PR to retrigger it.
+1 -1
View File
@@ -29,7 +29,7 @@
"@langfuse/shared": "workspace:*",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"https-proxy-agent": "^7.0.6",
"next": "16.2.1",
"next": "16.2.3",
"next-auth": "^4.24.13",
"zod": "^4.3.6"
},
@@ -68,7 +68,7 @@ types:
type: BlobStorageIntegrationType
bucketName:
type: string
docs: Name of the storage bucket
docs: Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens).
endpoint:
type: optional<string>
docs: Custom endpoint URL (required for S3_COMPATIBLE type)
+6
View File
@@ -10,6 +10,12 @@ service:
docs: Get a specific trace
method: GET
path: /traces/{traceId}
request:
name: GetTraceRequest
query-parameters:
fields:
type: optional<string>
docs: "Comma-separated list of fields to include in the response. Available field groups: 'core' (always included), 'io' (input, output, metadata), 'scores', 'observations', 'metrics'. If not specified, all fields are returned. Example: 'core,scores,metrics'. Note: Excluded 'observations' or 'scores' fields return empty arrays; excluded 'metrics' returns -1 for 'totalCost' and 'latency'."
path-parameters:
traceId:
type: string
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.165.0",
"version": "3.167.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -48,7 +48,7 @@
"husky": "^9.1.7",
"prettier": "^3.8.1",
"release-it": "^19.2.4",
"turbo": "2.8.20"
"turbo": "2.9.5"
},
"release-it": {
"git": {
+4 -10
View File
@@ -1,10 +1,9 @@
import tseslint from "typescript-eslint";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import turboConfig from "eslint-config-turbo/flat";
import "eslint-plugin-only-warn";
export default tseslint.config(
export default [
// Global ignores - include config files
{
name: "langfuse/ignores",
@@ -57,17 +56,12 @@ export default tseslint.config(
// Prettier (last)
eslintPluginPrettierRecommended,
// TypeScript config for TS files
// Note: The old config had a bug (duplicate extends) that prevented TS rules from applying
// Only adding parser + plugin + custom rules to match old behavior
// Layer repo-specific TS rules on top of Next's built-in flat TS config.
// Next already provides the parser and @typescript-eslint plugin here.
{
name: "langfuse/next/typescript",
files: ["**/*.ts", "**/*.tsx"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
languageOptions: {
parser: tseslint.parser,
globals: {
React: "readonly",
JSX: "readonly",
@@ -103,4 +97,4 @@ export default tseslint.config(
"react/jsx-key": ["error", { warnOnDuplicates: true }],
},
},
);
];
+2 -2
View File
@@ -16,9 +16,9 @@
],
"dependencies": {
"@eslint/js": "^9.39.2",
"eslint-config-next": "16.2.1",
"eslint-config-next": "16.2.3",
"eslint-config-prettier": "^10.1.8",
"eslint-config-turbo": "2.8.20",
"eslint-config-turbo": "2.9.5",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.0.0",
+14
View File
@@ -97,6 +97,15 @@ the same PR.
2. Update ClickHouse query/mapping logic in `src/server/clickhouse/*` and
related repositories.
3. Validate ingestion/read path impact in both `web` and `worker`.
4. If the change affects columns, types, or nullability of tables read by blob
storage export queries (`getTracesForBlobStorageExport`,
`getObservationsForBlobStorageExport`, `getScoresForBlobStorageExport`,
`getEventsForBlobStorageExport`, or the EventsQueryBuilder `export` field
set), fetch the latest published docs and check for discrepancies:
- https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage
- https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
Surface any mismatches in field names, types, nullability, or filter
descriptions so they can be addressed in the docs repo.
### Queue payload contract change
@@ -130,3 +139,8 @@ the same PR.
- Do not hand-edit generated artifacts under `prisma/generated/*` or `dist/*`.
- Avoid exposing server-only modules through `src/index.ts` if they must remain
frontend-safe.
- Changes to domain constants consumed by blob storage exports (e.g.
`LISTABLE_SCORE_TYPES` in `src/domain/scores.ts`, score data type enums)
should be reviewed against the blob storage export field reference docs for
consistency — fetch the latest page and surface any discrepancies:
https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
+9 -9
View File
@@ -84,17 +84,17 @@
"@azure/storage-blob": "^12.26.0",
"@clickhouse/client": "^1.13.0",
"@google-cloud/storage": "^7.19.0",
"@langchain/anthropic": "^1.3.12",
"@langchain/aws": "^1.3.3",
"@langchain/core": "^1.1.34",
"@langchain/google-genai": "^2.1.13",
"@langchain/google-vertexai": "^2.1.13",
"@langchain/openai": "^1.2.3",
"@langchain/anthropic": "^1.3.26",
"@langchain/aws": "^1.3.4",
"@langchain/core": "^1.1.39",
"@langchain/google-genai": "^2.1.26",
"@langchain/google-vertexai": "^2.1.26",
"@langchain/openai": "^1.4.2",
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"@prisma/client": "^6.19.3",
"@react-email/components": "^0.5.1",
"@react-email/render": "^1.2.1",
"@slack/oauth": "^3.0.4",
"@slack/oauth": "3.0.5",
"@slack/web-api": "^7.15.0",
"@types/bcryptjs": "^2.4.6",
"ajv": "^8.18.0",
@@ -108,9 +108,9 @@
"ioredis": "^5.8.2",
"ipaddr.js": "^2.2.0",
"jsonpath-plus": "10.3.0",
"langchain": "^1.2.15",
"langchain": "^1.3.0",
"langfuse-langchain": "3.38.20",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"lossless-json": "^4.1.1",
"next-auth": "^4.24.13",
"nodemailer": "^7.0.11",
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.165.0";
export const VERSION = "v3.167.2";
+26
View File
@@ -261,6 +261,24 @@ const EnvSchema = z.object({
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST: z
.string()
.optional()
.transform((s) =>
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
),
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SLACK_STATE_SECRET: z.string().optional(),
@@ -272,6 +290,14 @@ const EnvSchema = z.object({
.describe(
"How many records should be fetched from Slack, before we give up",
),
SLACK_PAGE_SIZE: z.coerce
.number()
.positive()
.int()
.max(1000)
.optional()
.default(1000) // Use high default to minimize number of API calls and hence avoid rate limits
.describe("Number of channels to fetch per Slack API page"),
HTTPS_PROXY: z.string().optional(),
LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT: z.coerce
+3 -1
View File
@@ -54,9 +54,11 @@ export type AuthHeaderValidVerificationResultIngestion = {
scope: ApiAccessScopeIngestion;
};
export type ApiAccessLevel = "organization" | "project" | "scores";
type BaseApiAccessScope = {
projectId: string | null;
accessLevel: "organization" | "project" | "scores";
accessLevel: ApiAccessLevel;
};
type ApiAccessScopeMetadata = {
+1
View File
@@ -30,6 +30,7 @@ export * from "./llm/utils";
export * from "./llm/types";
export * from "./llm/compileChatMessages";
export * from "./llm/testModelCall";
export * from "./llm/baseUrlValidation";
export * from "./llm/getInternalTracingHandler";
export * from "./utils/DatabaseReadStream";
export * from "./utils/transforms";
@@ -0,0 +1,133 @@
import { URL } from "node:url";
import { env } from "../../env";
import { logger } from "../logger";
import {
isHostnameBlocked,
isIPBlocked,
isIPAddress,
} from "../webhooks/ipBlocking";
import { resolveHost } from "../webhooks/validation";
export interface LlmBaseUrlValidationWhitelist {
hosts: string[];
ips: string[];
ip_ranges: string[];
}
export function llmBaseUrlWhitelistFromEnv(): LlmBaseUrlValidationWhitelist {
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
return {
hosts: [],
ips: [],
ip_ranges: [],
};
}
return {
hosts: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST || [],
ips: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS || [],
ip_ranges: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS || [],
};
}
export async function validateLlmConnectionBaseURL(
urlString: string,
whitelist: LlmBaseUrlValidationWhitelist = llmBaseUrlWhitelistFromEnv(),
): Promise<void> {
const effectiveWhitelist = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
? {
hosts: [],
ips: [],
ip_ranges: [],
}
: whitelist;
let url: URL;
try {
url = new URL(normalizeURL(urlString));
} catch {
throw new Error("Invalid URL syntax");
}
if (!["https:", "http:"].includes(url.protocol)) {
throw new Error("Only HTTP and HTTPS protocols are allowed");
}
const hostname = normalizeHostname(url.hostname);
if (effectiveWhitelist.hosts.includes(hostname)) {
return;
}
if (isHostnameBlocked(hostname)) {
throw new Error("Blocked hostname detected");
}
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && url.protocol !== "https:") {
throw new Error("Only HTTPS base URLs are allowed on Langfuse Cloud");
}
if (isIPAddress(hostname)) {
if (
isIPBlocked(
hostname,
effectiveWhitelist.ips,
effectiveWhitelist.ip_ranges,
)
) {
logger.warn(
`LLM base URL validation blocked IP address in hostname: ${hostname}`,
);
throw new Error("Blocked IP address detected");
}
return;
}
let ips: string[];
try {
ips = await resolveHost(hostname);
} catch {
// DNS resolution is best-effort here so valid custom gateways do not fail at write time.
return;
}
for (const ip of ips) {
if (isIPBlocked(ip, effectiveWhitelist.ips, effectiveWhitelist.ip_ranges)) {
logger.warn(
`LLM base URL validation blocked resolved IP address: ${ip} for hostname: ${hostname}`,
);
throw new Error("Blocked IP address detected");
}
}
}
function normalizeURL(urlString: string): string {
let normalized = urlString.trim();
try {
normalized = decodeURIComponent(normalized);
} catch {
throw new Error("Invalid URL encoding");
}
try {
normalized = normalized.normalize("NFC");
} catch {
throw new Error("Invalid unicode in URL");
}
return normalized;
}
function normalizeHostname(hostname: string): string {
let normalized = hostname.toLowerCase();
try {
normalized = new URL(`http://${normalized}`).hostname;
} catch {
// Keep the original hostname so URL parsing can fail consistently elsewhere.
}
return normalized;
}
@@ -43,7 +43,11 @@ import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
import { ProxyAgent } from "undici";
import { getInternalTracingHandler } from "./getInternalTracingHandler";
import { decrypt } from "../../encryption";
import { decryptAndParseExtraHeaders } from "./utils";
import {
decryptAndParseExtraHeaders,
executeWithRuntimeTimeout,
RUNTIME_TIMEOUT_ADAPTERS,
} from "./utils";
import { logger } from "../logger";
import { LLMCompletionError } from "./errors";
@@ -453,6 +457,19 @@ export async function fetchLLMCompletion(
metadata: traceSinkParams?.metadata,
};
const runtimeTimeoutEnabled = RUNTIME_TIMEOUT_ADAPTERS.has(
modelParams.adapter,
);
const runtimeTimeoutController = runtimeTimeoutEnabled
? new AbortController()
: undefined;
const runConfigWithTimeout = runtimeTimeoutController
? {
...runConfig,
signal: runtimeTimeoutController.signal,
}
: runConfig;
const thinkingTypes = getThinkingBlockTypes(modelParams.adapter);
try {
@@ -460,17 +477,24 @@ export async function fetchLLMCompletion(
if (params.structuredOutputSchema) {
// Thinking-capable adapters may produce reasoning blocks that corrupt JSON schema
// parsing. Force function calling so the parser reads from tool_calls instead.
const structuredOutputSchema = params.structuredOutputSchema;
const structuredOutputConfig =
thinkingTypes != null
? { method: "functionCalling" as const }
: undefined;
const structuredOutput = await (chatModel as ChatOpenAI)
.withStructuredOutput(
params.structuredOutputSchema,
structuredOutputConfig,
)
.invoke(finalMessages, runConfig);
const structuredOutput = await executeWithRuntimeTimeout({
enabled: runtimeTimeoutEnabled,
timeoutMs,
abortController: runtimeTimeoutController,
operation: () =>
(chatModel as ChatOpenAI)
.withStructuredOutput(
structuredOutputSchema,
structuredOutputConfig,
)
.invoke(finalMessages, runConfigWithTimeout),
});
return structuredOutput;
}
@@ -481,9 +505,15 @@ export async function fetchLLMCompletion(
function: tool,
}));
const result = await chatModel
.bindTools(langchainTools)
.invoke(finalMessages, runConfig);
const result = await executeWithRuntimeTimeout({
enabled: runtimeTimeoutEnabled,
timeoutMs,
abortController: runtimeTimeoutController,
operation: () =>
chatModel
.bindTools(langchainTools)
.invoke(finalMessages, runConfigWithTimeout),
});
// For thinking adapters, strip reasoning blocks from content before parsing
// so ToolCallResponseSchema can validate. Extract reasoning separately.
@@ -512,20 +542,37 @@ export async function fetchLLMCompletion(
}
if (streaming)
return chatModel
.pipe(new BytesOutputParser())
.stream(finalMessages, runConfig);
return await executeWithRuntimeTimeout({
enabled: runtimeTimeoutEnabled,
timeoutMs,
abortController: runtimeTimeoutController,
operation: () =>
chatModel
.pipe(new BytesOutputParser())
.stream(finalMessages, runConfigWithTimeout),
});
// content with thinking blocks can't be handled by StringOutputParser
// Invoke model directly and extract text + reasoning separately.
if (thinkingTypes != null) {
const aiMessage = await chatModel.invoke(finalMessages, runConfig);
const aiMessage = await executeWithRuntimeTimeout({
enabled: runtimeTimeoutEnabled,
timeoutMs,
abortController: runtimeTimeoutController,
operation: () => chatModel.invoke(finalMessages, runConfigWithTimeout),
});
return extractCompletionWithReasoning(aiMessage, thinkingTypes);
}
const completion = await chatModel
.pipe(new StringOutputParser())
.invoke(finalMessages, runConfig);
const completion = await executeWithRuntimeTimeout({
enabled: runtimeTimeoutEnabled,
timeoutMs,
abortController: runtimeTimeoutController,
operation: () =>
chatModel
.pipe(new StringOutputParser())
.invoke(finalMessages, runConfigWithTimeout),
});
return completion;
} catch (e) {
@@ -4,6 +4,11 @@ import { processEventBatch } from "../ingestion/processEventBatch";
import { logger } from "../logger";
import { traceException } from "../instrumentation";
type TracedEvent = {
type: string;
body: Record<string, unknown>;
};
/**
* Extracts and merges generation details from a list of processed events.
* Handles multiple generation-create and generation-update events with the same id.
@@ -73,6 +78,66 @@ export function extractGenerationDetails(
};
}
export function prepareTracedEventsForIngestion(
events: TracedEvent[],
{ environment, prompt }: Pick<TraceSinkParams, "environment" | "prompt">,
): TracedEvent[] {
const blockedSpanIds = new Set<string>();
const blockedSpanNames = [
"RunnableLambda",
"StructuredOutputParser",
"StrOutputParser",
"JsonOutputParser",
];
for (const event of events) {
const eventName = event.body.name;
if (typeof eventName !== "string" || eventName.length === 0) {
continue;
}
if (
blockedSpanNames.includes(eventName) &&
typeof event.body.id === "string"
) {
blockedSpanIds.add(event.body.id);
}
}
return events
.filter((event) => {
if (typeof event.body.id === "string") {
return !blockedSpanIds.has(event.body.id);
}
return true;
})
.map((event) => {
return {
...event,
body: {
...event.body,
environment,
},
};
})
.map((event) => {
if (event.type === "generation-create" && prompt) {
return {
...event,
body: {
...event.body,
promptName: prompt.name,
promptVersion: prompt.version,
},
};
}
return event;
});
}
export function getInternalTracingHandler(traceSinkParams: TraceSinkParams): {
handler: CallbackHandler;
processTracedEvents: () => Promise<void>;
@@ -91,46 +156,13 @@ export function getInternalTracingHandler(traceSinkParams: TraceSinkParams): {
traceSinkParams.targetProjectId,
);
// Filter out unnecessary Langchain spans
const blockedSpanIds = new Set();
const blockedSpanNames = [
"RunnableLambda",
"StructuredOutputParser",
"StrOutputParser",
"JsonOutputParser",
];
for (const event of events) {
const eventName = "name" in event.body ? event.body.name : "";
if (!eventName) continue;
if (blockedSpanNames.includes(eventName) && "id" in event.body) {
blockedSpanIds.add(event.body.id);
}
}
const processedEvents = events
.filter((event) => {
if ("id" in event.body) {
return !blockedSpanIds.has(event.body.id);
}
return true;
})
.map((event: any) => {
// to add the prompt name and version to only generation-type observations
if (event.type === "generation-create" && prompt) {
return {
...event,
body: {
...event.body,
...{ promptName: prompt.name, promptVersion: prompt.version },
},
};
}
return event;
});
const processedEvents = prepareTracedEventsForIngestion(
events as TracedEvent[],
{
environment,
prompt,
},
);
await processEventBatch(
JSON.parse(JSON.stringify(processedEvents)), // stringify to emulate network event batch from network call
+40
View File
@@ -1,9 +1,49 @@
import { z } from "zod";
import { decrypt } from "../../encryption";
import { LLMAdapter } from "./types";
const ExtraHeaderSchema = z.record(z.string(), z.string());
export const RUNTIME_TIMEOUT_ADAPTERS = new Set([
LLMAdapter.VertexAI,
LLMAdapter.GoogleAIStudio,
]);
export async function executeWithRuntimeTimeout<T>({
enabled,
timeoutMs,
abortController,
operation,
}: {
enabled: boolean;
timeoutMs: number;
abortController?: AbortController;
operation: () => Promise<T>;
}): Promise<T> {
if (!enabled) {
return operation();
}
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation(),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
abortController?.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
export function decryptAndParseExtraHeaders(
extraHeaders: string | null | undefined,
) {
+18 -10
View File
@@ -1,5 +1,9 @@
import chunk from "lodash/chunk";
import { prisma } from "../db";
const BATCH_SIZE = 10_000;
interface MediaFileRef {
id: string;
bucketPath: string;
@@ -55,16 +59,20 @@ export async function deleteMediaFiles(params: {
return 0;
}
// Delete from S3 first
await storageClient.deleteFiles(mediaFiles.map((f) => f.bucketPath));
// Delete from PostgreSQL (cascades to traceMedia/observationMedia)
await prisma.media.deleteMany({
where: {
id: { in: mediaFiles.map((f) => f.id) },
projectId,
},
});
// Process in batches to stay under PostgreSQL's 32,767 bind variable limit.
// S3 is deleted before PG per batch to avoid orphaned storage files.
// All callers target expired or soft-deleted media with retry semantics,
// so partial failure self-heals on retry (S3 deletes are idempotent).
const chunks = chunk(mediaFiles, BATCH_SIZE);
for (const batch of chunks) {
await storageClient.deleteFiles(batch.map((f) => f.bucketPath));
await prisma.media.deleteMany({
where: {
id: { in: batch.map((f) => f.id) },
projectId,
},
});
}
return mediaFiles.length;
}
@@ -185,6 +185,39 @@ const FIELD_SETS = {
"toolCalls",
"toolCallNames",
],
baseWithoutTools: [
"id",
"type",
"projectId",
"name",
"modelParameters",
"startTime",
"endTime",
"traceId",
"completionStartTime",
"providedUsageDetails",
"usageDetails",
"providedCostDetails",
"costDetails",
"level",
"environment",
"bookmarked",
"public",
"statusMessage",
"version",
"parentObservationId",
"createdAt",
"updatedAt",
"providedModelName",
"totalCost",
"promptId",
"promptName",
"promptVersion",
"internalModelId",
"userId",
"sessionId",
"traceName",
],
calculated: ["latency", "timeToFirstToken"],
io: ["input", "output"],
metadata: ["metadata"],
@@ -375,16 +375,35 @@ export async function upsertDatasetItem(
[Implementation.VERSIONED]: async () => {
// VERSIONED: Invalidate old row by setting valid_to, then create new row
await prisma.$transaction(async (tx) => {
const newValidFrom = new Date();
// 0. Re-read if there is an existing item to get the validFrom timestamp
const current = await tx.datasetItem.findFirst({
where: {
id: itemId,
projectId: props.projectId,
validTo: null,
},
orderBy: {
validFrom: "desc",
},
});
if (current && current.datasetId !== dataset.id) {
throw new LangfuseNotFoundError(
`Dataset item with id ${itemId} not found for project ${props.projectId}`,
);
}
const baseTs = current?.validFrom.getTime() ?? 0;
const newValidFrom = new Date(Math.max(Date.now(), baseTs + 1));
// 1. If updating existing item, invalidate the current version
if (existingItem) {
if (current) {
await tx.datasetItem.update({
where: {
id_projectId_validFrom: {
id: existingItem.id,
id: current.id,
projectId: props.projectId,
validFrom: existingItem.validFrom,
validFrom: current.validFrom,
},
},
data: {
@@ -337,6 +337,7 @@ export const getObservationsForTraceFromEventsTable = async (params: {
limit: MAX_OBSERVATIONS_PER_TRACE + 1,
offset: 0,
select: "rows",
selectToolData: false,
tags: { kind: "byTraceId" },
},
);
@@ -394,6 +395,7 @@ export const getObservationsWithModelDataFromEventsTable = async (
async function getObservationsFromEventsTableInternal<T>(
opts: ObservationTableQuery & {
select: "count" | "rows";
selectToolData?: boolean;
tags: Record<string, string>;
},
): Promise<Array<T>> {
@@ -401,6 +403,7 @@ async function getObservationsFromEventsTableInternal<T>(
projectId,
filter,
selectIOAndMetadata,
selectToolData = true,
renderingProps = DEFAULT_RENDERING_PROPS,
limit,
offset,
@@ -461,7 +464,10 @@ async function getObservationsFromEventsTableInternal<T>(
if (opts.select === "count") {
queryBuilder.selectFieldSet("count");
} else {
queryBuilder.selectFieldSet("base", "calculated");
queryBuilder.selectFieldSet(
selectToolData ? "base" : "baseWithoutTools",
"calculated",
);
if (selectIOAndMetadata) {
queryBuilder
.selectIO(
@@ -175,9 +175,7 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
prompt_id,
prompt_name,
prompt_version,
tool_definitions,
tool_calls,
tool_call_names,
${includeIO === true ? "tool_definitions, tool_calls, tool_call_names," : ""}
created_at,
updated_at,
event_ts
@@ -506,6 +506,7 @@ export const getTraceById = async ({
clickhouseFeatureTag = "tracing",
preferredClickhouseService,
excludeInputOutput = false,
excludeMetadata = false,
}: {
traceId: string;
projectId: string;
@@ -516,6 +517,8 @@ export const getTraceById = async ({
preferredClickhouseService?: PreferredClickhouseService;
/** When true, sets input/output columns to empty in the query to reduce database load */
excludeInputOutput?: boolean;
/** When true, sets metadata column to empty in the query to reduce database load */
excludeMetadata?: boolean;
}) => {
const records = await measureAndReturn({
operationName: "getTraceById",
@@ -550,13 +553,14 @@ export const getTraceById = async ({
: renderingProps.truncated
? `leftUTF8(output, ${env.LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT})`
: "output";
const metadataColumn = excludeMetadata ? "'{}'" : "metadata";
const query = `
SELECT
id,
name as name,
user_id as user_id,
metadata as metadata,
${metadataColumn} as metadata,
release as release,
version as version,
project_id,
@@ -14,12 +14,39 @@ import { env } from "../../env";
import { prisma } from "../../db";
import { encrypt, decrypt } from "../../encryption";
/**
* Error thrown by SlackService when a Slack API call fails.
* Preserves the Slack error code so callers can provide user-friendly messages.
*/
export class SlackApiError extends Error {
constructor(
message: string,
public readonly slackErrorCode?: string,
) {
super(message);
this.name = "SlackApiError";
}
}
/** OAuth scopes requested when installing the Slack app. */
export const SLACK_BOT_SCOPES = [
"channels:read", // read public channels
"groups:read", // read private channels that the bot is a member of
"chat:write", // send messages to channels the bot is a member of
"chat:write.public", // send messages to public channels that the bot is not a member of
] as const;
// Types for Slack integration
export interface SlackChannel {
id: string;
name: string;
isPrivate: boolean;
isMember: boolean;
isPrivate?: boolean;
isMember?: boolean;
}
export interface GetChannelsResult {
channels: SlackChannel[];
hasPrivateChannelAccess: boolean;
}
export interface SlackMessageParams {
@@ -96,7 +123,7 @@ export class SlackService {
clientSecret: env.SLACK_CLIENT_SECRET!,
stateSecret: env.SLACK_STATE_SECRET!,
installUrlOptions: {
scopes: ["channels:read", "chat:write", "chat:write.public"],
scopes: SLACK_BOT_SCOPES as unknown as string[],
},
installationStore: {
storeInstallation: async (installation) => {
@@ -280,7 +307,9 @@ export class SlackService {
throw new Error("No bot token found for project");
}
const client = new WebClient(auth.botToken);
const client = new WebClient(auth.botToken, {
retryConfig: { retries: 3, maxRetryTime: 90_000 },
});
logger.debug("Created WebClient for project", { projectId });
return client;
@@ -301,14 +330,15 @@ export class SlackService {
*/
private async getChannelsRecursive(
client: WebClient,
channelTypes: string = "public_channel,private_channel",
cursor?: string,
fetchedRecords: number = 0,
): Promise<SlackChannel[]> {
try {
const result = await client.conversations.list({
exclude_archived: true,
types: "public_channel",
limit: 200,
types: channelTypes,
limit: env.SLACK_PAGE_SIZE,
cursor: cursor,
});
@@ -333,10 +363,11 @@ export class SlackService {
try {
const nextPageChannels = await this.getChannelsRecursive(
client,
channelTypes,
nextCursor,
fetchedRecords + channels.length,
);
return [...channels, ...nextPageChannels];
return channels.concat(nextPageChannels);
} catch (error) {
logger.error(
`Failed to retrieve next page of channels, returning only already fetched`,
@@ -347,6 +378,55 @@ export class SlackService {
return channels;
} catch (error) {
logger.error("Failed to fetch channels recursively", { error, cursor });
throw error;
}
}
/**
* Get channels accessible to the bot.
*/
async getChannels(client: WebClient): Promise<GetChannelsResult> {
try {
const channels = await this.getChannelsRecursive(
client,
"public_channel,private_channel",
);
logger.debug("Retrieved channels from Slack", {
channelCount: channels.length,
});
return { channels, hasPrivateChannelAccess: true };
} catch (error: any) {
// we added `groups:read` scope after initial release, so older installations may not have it.
// Detect this case and fall back to fetching only public channels instead of failing completely.
const isMissingGroupsRead =
error?.data?.error === "missing_scope" &&
error?.data?.needed === "groups:read";
if (isMissingGroupsRead) {
logger.info(
"Bot token lacks groups:read scope, falling back to public channels only",
);
try {
const channels = await this.getChannelsRecursive(
client,
"public_channel",
);
return { channels, hasPrivateChannelAccess: false };
} catch (fallbackError) {
logger.error("Failed to fetch public channels fallback", {
error: fallbackError,
});
throw new Error(
`Failed to fetch channels: ${fallbackError instanceof Error ? fallbackError.message : "Unknown error"}`,
);
}
}
logger.error("Failed to fetch channels", { error });
throw new Error(
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
);
@@ -354,22 +434,24 @@ export class SlackService {
}
/**
* Get channels accessible to the bot
* Get channel info by ID via conversations.info.
*/
async getChannels(client: WebClient): Promise<SlackChannel[]> {
async getChannelInfo(
client: WebClient,
channelId: string,
): Promise<SlackChannel | null> {
try {
const channels = await this.getChannelsRecursive(client);
logger.debug("Retrieved channels from Slack", {
channelCount: channels.length,
});
return channels;
const result = await client.conversations.info({ channel: channelId });
if (!result.ok || !result.channel) return null;
return {
id: result.channel.id!,
name: result.channel.name!,
isPrivate: result.channel.is_private || false,
isMember: result.channel.is_member || false,
};
} catch (error) {
logger.error("Failed to fetch channels", { error });
throw new Error(
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
);
logger.warn("Failed to fetch channel info", { error, channelId });
return null;
}
}
@@ -401,13 +483,16 @@ export class SlackService {
});
return response;
} catch (error) {
} catch (error: any) {
logger.error("Failed to send message", {
error,
channelId: params.channelId,
});
throw new Error(
const slackErrorCode = error?.data?.error as string | undefined;
throw new SlackApiError(
`Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
slackErrorCode,
);
}
}
@@ -50,12 +50,14 @@ export function isIPBlocked(
whiteListedIpSegments: string[],
): boolean {
try {
const cleanedIp = normalizeIPAddress(ipString);
// Check if IP is in whitelist first
if (whitelistedIPs.includes(ipString.toLowerCase().trim())) {
if (whitelistedIPs.includes(cleanedIp)) {
return false;
}
const ip = ipaddr.parse(ipString);
const ip = ipaddr.parse(cleanedIp);
const whitelistedSegments = whiteListedIpSegments.map((cidr) => {
const [addr, bits] = cidr.split("/");
@@ -87,8 +89,7 @@ export function isIPBlocked(
* Check if a string is an IP address
*/
export function isIPAddress(hostname: string): boolean {
// Remove brackets from IPv6 addresses
const cleaned = hostname.replace(/^\[|\]$/g, "");
const cleaned = normalizeIPAddress(hostname);
try {
ipaddr.parse(cleaned);
@@ -137,3 +138,10 @@ export function isHostnameBlocked(hostname: string): boolean {
return false;
}
function normalizeIPAddress(ipString: string): string {
return ipString
.toLowerCase()
.trim()
.replace(/^\[|\]$/g, "");
}
+1550 -1018
View File
File diff suppressed because it is too large Load Diff
+36 -63
View File
@@ -4,72 +4,45 @@ packages:
- "worker"
- "packages/**"
- "ee"
# 8 day delay for new dep upgrades to reduce supply chain attack risk
minimumReleaseAge: 11520
# 5 day delay for new dep upgrades to reduce supply chain attack risk
minimumReleaseAge: 7200
# TODO: remove exclusions below!
# the exclusions are temporary so that we can set the 8 day limit without downgrading packages.
# the exclusions are temporary so that we can set the 5 day limit without downgrading packages.
# this list is version-specific
minimumReleaseAgeExclude:
- "picomatch@4.0.4"
- "graphql@16.13.2"
- "use-sync-external-store@1.6.0"
- "release-it@19.2.4"
- "@codemirror/language@6.12.3"
- "@sentry/core@10.46.0"
- "@sentry/node-core@10.46.0"
- "@sentry-internal/browser-utils@10.46.0"
- "@sentry-internal/replay@10.46.0"
- "@sentry/opentelemetry@10.46.0"
- "@sentry-internal/feedback@10.46.0"
- "@sentry-internal/replay-canvas@10.46.0"
- "@sentry/browser@10.46.0"
- "@sentry/node@10.46.0"
- "@sentry/react@10.46.0"
- "@sentry/vercel-edge@10.46.0"
- "@sentry/nextjs@10.46.0"
- "@opentelemetry/context-async-hooks@2.6.1"
- "@opentelemetry/core@2.6.1"
- "@opentelemetry/resources@2.6.1"
- "@opentelemetry/sdk-trace-base@2.6.1"
- "undici@7.24.6"
- "eslint-plugin-react-hooks@7.0.1"
- "react-is@19.2.4"
- "@next/swc-darwin-arm64@16.2.1"
- "@next/swc-darwin-x64@16.2.1"
- "@next/swc-linux-arm64-gnu@16.2.1"
- "@next/swc-linux-arm64-musl@16.2.1"
- "@next/swc-linux-x64-gnu@16.2.1"
- "@next/swc-linux-x64-musl@16.2.1"
- "@next/swc-win32-arm64-msvc@16.2.1"
- "@next/swc-win32-x64-msvc@16.2.1"
- "eslint-config-next@16.2.1"
- "@next/eslint-plugin-next@16.2.1"
- "@next/env@16.2.1"
- "next@16.2.1"
- "@vitest/pretty-format@4.1.2"
- "@vitest/spy@4.1.2"
- "@vitest/utils@4.1.2"
- "@vitest/mocker@4.1.2"
- "@vitest/runner@4.1.2"
- "@vitest/snapshot@4.1.2"
- "@vitest/expect@4.1.2"
- "vitest@4.1.2"
- "@vitest/coverage-v8@4.1.2"
- "path-to-regexp@8.3.0"
- "lodash@4.17.23"
- "zod-to-json-schema@3.25.2"
- "langfuse@3.38.20"
- "langfuse-langchain@3.38.20"
- "langfuse-core@3.38.20"
- "@modelcontextprotocol/sdk@1.29.0"
- "@prisma/instrumentation@6.19.3"
- "prisma@6.19.3"
- "@prisma/client@6.19.3"
- "@prisma/config@6.19.3"
- "@prisma/engines@6.19.3"
- "@prisma/debug@6.19.3"
- "@prisma/fetch-engine@6.19.3"
- "@prisma/get-platform@6.19.3"
- "eslint-config-turbo@2.9.5"
- "eslint-plugin-turbo@2.9.5"
- "turbo@2.9.5"
- "@turbo/darwin-64@2.9.5"
- "@turbo/darwin-arm64@2.9.5"
- "@turbo/windows-64@2.9.5"
- "@turbo/windows-arm64@2.9.5"
- "@turbo/linux-64@2.9.5"
- "@turbo/linux-arm64@2.9.5"
- "vitest@4.1.4"
- "@vitest/mocker@4.1.4"
- "@vitest/pretty-format@4.1.4"
- "@vitest/snapshot@4.1.4"
- "@vitest/spy@4.1.4"
- "@vitest/runner@4.1.4"
- "@vitest/expect@4.1.4"
- "@vitest/utils@4.1.4"
- "@vitest/coverage-v8@4.1.4"
- "axios@1.15.0"
- "next@16.2.3"
- "@next/env@16.2.3"
- "eslint-config-next@16.2.3"
- "@next/eslint-plugin-next@16.2.3"
- "@next/swc-darwin-arm64@16.2.3"
- "@next/swc-darwin-x64@16.2.3"
- "@next/swc-linux-arm64-gnu@16.2.3"
- "@next/swc-linux-arm64-musl@16.2.3"
- "@next/swc-linux-x64-gnu@16.2.3"
- "@next/swc-linux-x64-musl@16.2.3"
- "@next/swc-win32-arm64-msvc@16.2.3"
- "@next/swc-win32-x64-msvc@16.2.3"
- "defu@6.1.7"
- "hono@4.12.12"
allowBuilds:
"@prisma/client": true
"@prisma/engines": true
+461
View File
@@ -0,0 +1,461 @@
#!/usr/bin/env bash
set -euo pipefail
CODEX_SERVICES_ROOT="${CODEX_SERVICES_ROOT:-$PWD/.codex/services}"
# NOTE: POSTGRES_PORT and POSTGRES_USER are effectively immutable once
# `$CODEX_SERVICES_ROOT/postgres/data` is initialized. Changing either value on
# reruns requires deleting the initialized Postgres data directory and allowing
# `initdb` to recreate the cluster with the new settings.
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
REDIS_PORT="${REDIS_PORT:-6379}"
CLICKHOUSE_HTTP_PORT="${CLICKHOUSE_HTTP_PORT:-8123}"
CLICKHOUSE_NATIVE_PORT="${CLICKHOUSE_NATIVE_PORT:-9000}"
MINIO_API_PORT="${MINIO_API_PORT:-9090}"
MINIO_CONSOLE_PORT="${MINIO_CONSOLE_PORT:-9091}"
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-postgres}"
REDIS_AUTH="${REDIS_AUTH:-myredissecret}"
CLICKHOUSE_USER="${CLICKHOUSE_USER:-clickhouse}"
CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-clickhouse}"
MINIO_ROOT_USER="${MINIO_ROOT_USER:-minio}"
MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-miniosecret}"
MINIO_RELEASE_TAG="${MINIO_RELEASE_TAG:-RELEASE.2025-09-07T16-13-09Z}"
MC_RELEASE_TAG="${MC_RELEASE_TAG:-RELEASE.2025-08-13T08-35-41Z}"
MINIO_SHA256_AMD64="${MINIO_SHA256_AMD64:-7c5bd8512c6e966455b1d198209358b2d191c77a83ab377c4073281065fb855f}"
MINIO_SHA256_ARM64="${MINIO_SHA256_ARM64:-5c83cd2cf151717ba0243f73e1c7802ff36e272b67144bdd7f1f7d684fd6f03d}"
MC_SHA256_AMD64="${MC_SHA256_AMD64:-01f866e9c5f9b87c2b09116fa5d7c06695b106242d829a8bb32990c00312e891}"
MC_SHA256_ARM64="${MC_SHA256_ARM64:-14c8c9616cfce4636add161304353244e8de383b2e2752c0e9dad01d4c27c12c}"
export DEBIAN_FRONTEND=noninteractive
ensure_apt_package() {
local package="$1"
if dpkg -s "$package" >/dev/null 2>&1; then
return 0
fi
if [ -z "${CODEX_APT_UPDATED:-}" ]; then
apt-get update
CODEX_APT_UPDATED=1
fi
apt-get install -y "$package"
}
stop_service_if_running() {
local service_name="$1"
if command -v systemctl >/dev/null 2>&1; then
systemctl stop "$service_name" >/dev/null 2>&1 || true
fi
if command -v service >/dev/null 2>&1; then
service "$service_name" stop >/dev/null 2>&1 || true
fi
}
stop_system_postgres_clusters() {
if command -v pg_lsclusters >/dev/null 2>&1 && command -v pg_ctlcluster >/dev/null 2>&1; then
while read -r version cluster_name _ status _; do
if [ "$status" = "online" ]; then
pg_ctlcluster "$version" "$cluster_name" stop >/dev/null 2>&1 || true
fi
done < <(pg_lsclusters --no-header 2>/dev/null || true)
fi
stop_service_if_running postgresql
}
ensure_clickhouse_repo() {
ensure_apt_package ca-certificates
ensure_apt_package curl
ensure_apt_package gnupg
local keyring="/etc/apt/keyrings/clickhouse.gpg"
local source_file="/etc/apt/sources.list.d/clickhouse.list"
mkdir -p /etc/apt/keyrings
if [ ! -f "$keyring" ]; then
curl -fsSL https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key \
| gpg --dearmor -o "$keyring"
fi
if [ ! -f "$source_file" ]; then
echo "deb [signed-by=$keyring] https://packages.clickhouse.com/deb stable main" > "$source_file"
apt-get update
fi
}
ensure_postgres_binaries() {
ensure_apt_package postgresql
ensure_apt_package postgresql-client
stop_system_postgres_clusters
}
ensure_redis_binary() {
ensure_apt_package redis-server
stop_service_if_running redis-server
}
ensure_clickhouse_binaries() {
if command -v clickhouse-server >/dev/null 2>&1 && command -v clickhouse-client >/dev/null 2>&1; then
stop_service_if_running clickhouse-server
return 0
fi
ensure_clickhouse_repo
apt-get install -y clickhouse-server clickhouse-client
stop_service_if_running clickhouse-server
}
detect_minio_arch() {
local machine_arch
machine_arch="$(uname -m)"
case "$machine_arch" in
x86_64|amd64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
echo "Unsupported architecture for MinIO binaries: $machine_arch" >&2
exit 1
;;
esac
}
download_and_verify_sha256() {
local url="$1"
local output_path="$2"
local expected_sha256="$3"
local tmp_download
tmp_download="$(mktemp)"
trap 'rm -f "$tmp_download"' RETURN
curl -fsSL "$url" -o "$tmp_download"
local actual_sha256
actual_sha256="$(sha256sum "$tmp_download" | awk '{print $1}')"
if [ "$actual_sha256" != "$expected_sha256" ]; then
echo "SHA256 mismatch for $url" >&2
echo "expected: $expected_sha256" >&2
echo "actual: $actual_sha256" >&2
return 1
fi
mv "$tmp_download" "$output_path"
trap - RETURN
}
ensure_minio_binaries() {
local bin_dir="$CODEX_SERVICES_ROOT/bin"
local minio_arch
local minio_sha256
local mc_sha256
mkdir -p "$bin_dir"
minio_arch="$(detect_minio_arch)"
case "$minio_arch" in
amd64)
minio_sha256="$MINIO_SHA256_AMD64"
mc_sha256="$MC_SHA256_AMD64"
;;
arm64)
minio_sha256="$MINIO_SHA256_ARM64"
mc_sha256="$MC_SHA256_ARM64"
;;
esac
if [ ! -x "$bin_dir/minio" ]; then
download_and_verify_sha256 \
"https://dl.min.io/server/minio/release/linux-${minio_arch}/archive/minio.${MINIO_RELEASE_TAG}" \
"$bin_dir/minio" \
"$minio_sha256"
chmod +x "$bin_dir/minio"
fi
if [ ! -x "$bin_dir/mc" ]; then
download_and_verify_sha256 \
"https://dl.min.io/client/mc/release/linux-${minio_arch}/archive/mc.${MC_RELEASE_TAG}" \
"$bin_dir/mc" \
"$mc_sha256"
chmod +x "$bin_dir/mc"
fi
export PATH="$bin_dir:$PATH"
}
find_postgres_bin() {
local name="$1"
if command -v "$name" >/dev/null 2>&1; then
command -v "$name"
return 0
fi
find /usr/lib/postgresql -type f -name "$name" 2>/dev/null | sort -V | tail -n 1
}
wait_for_port() {
local host="$1"
local port="$2"
local timeout_seconds="${3:-45}"
local deadline=$((SECONDS + timeout_seconds))
until (echo >"/dev/tcp/$host/$port") >/dev/null 2>&1; do
if [ "$SECONDS" -ge "$deadline" ]; then
return 1
fi
sleep 1
done
}
wait_for_http() {
local url="$1"
local timeout_seconds="${2:-45}"
local deadline=$((SECONDS + timeout_seconds))
until curl -fsS "$url" >/dev/null 2>&1; do
if [ "$SECONDS" -ge "$deadline" ]; then
return 1
fi
sleep 1
done
}
escape_sql_literal() {
local value="$1"
value="${value//\\/\\\\}"
printf "%s" "${value//\'/\'\'}"
}
escape_clickhouse_identifier() {
local value="$1"
printf '`%s`' "${value//\`/\`\`}"
}
escape_redis_config_string() {
local value="$1"
# Redis treats backslashes and double-quotes as escape delimiters inside
# quoted config strings, so both must be escaped before writing requirepass.
value="${value//\\/\\\\}"
value="${value//$'\n'/\\n}"
value="${value//\"/\\\"}"
printf "%s" "$value"
}
ensure_postgres_running() {
ensure_postgres_binaries
local initdb
local pg_ctl
local psql
local pg_isready
initdb="$(find_postgres_bin initdb)"
pg_ctl="$(find_postgres_bin pg_ctl)"
psql="$(find_postgres_bin psql)"
pg_isready="$(find_postgres_bin pg_isready)"
if [ -z "$initdb" ] || [ -z "$pg_ctl" ] || [ -z "$psql" ] || [ -z "$pg_isready" ]; then
echo "Unable to find required PostgreSQL binaries (initdb, pg_ctl, psql, pg_isready)."
exit 1
fi
local pg_root="$CODEX_SERVICES_ROOT/postgres"
local pg_data="$pg_root/data"
local pg_log="$pg_root/postgres.log"
local pg_socket_dir="$pg_root"
local -a pg_runner
mkdir -p "$pg_root"
if [ "${EUID:-$(id -u)}" -eq 0 ] && id -u postgres >/dev/null 2>&1; then
chown -R postgres:postgres "$pg_root"
pg_runner=(runuser -u postgres --)
else
pg_runner=()
fi
if [ ! -f "$pg_data/PG_VERSION" ]; then
"${pg_runner[@]}" "$initdb" -D "$pg_data" -U "$POSTGRES_USER" --auth-host=md5 --auth-local=trust >/dev/null
{
echo "listen_addresses = '127.0.0.1'"
echo "port = $POSTGRES_PORT"
echo "log_statement = 'all'"
echo "timezone = 'UTC'"
echo "unix_socket_directories = '$pg_socket_dir'"
} >> "$pg_data/postgresql.conf"
fi
if ! "${pg_runner[@]}" "$pg_ctl" -D "$pg_data" status >/dev/null 2>&1; then
"${pg_runner[@]}" "$pg_ctl" -D "$pg_data" -l "$pg_log" -w start
fi
if ! "$pg_isready" -h "$pg_socket_dir" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" >/dev/null 2>&1; then
echo "PostgreSQL did not become ready on socket $pg_socket_dir (port $POSTGRES_PORT)"
exit 1
fi
PGPASSWORD="${POSTGRES_PASSWORD}" "${pg_runner[@]}" "$psql" -h "$pg_socket_dir" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d postgres -v postgres_user="$POSTGRES_USER" -v postgres_db="$POSTGRES_DB" -v postgres_password="$POSTGRES_PASSWORD" <<SQL >/dev/null
SELECT format('ALTER USER %I WITH PASSWORD %L', :'postgres_user', :'postgres_password')\gexec
SELECT format('CREATE DATABASE %I', :'postgres_db')
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'postgres_db')\gexec
SQL
}
ensure_redis_running() {
ensure_redis_binary
local redis_root="$CODEX_SERVICES_ROOT/redis"
local redis_conf="$redis_root/redis.conf"
local redis_log="$redis_root/redis.log"
local redis_pid="$redis_root/redis.pid"
local redis_auth_escaped
mkdir -p "$redis_root"
if wait_for_port 127.0.0.1 "$REDIS_PORT" 1; then
echo "Redis already running on 127.0.0.1:$REDIS_PORT; keeping existing runtime config."
return 0
fi
redis_auth_escaped="$(escape_redis_config_string "$REDIS_AUTH")"
cat > "$redis_conf" <<CONF
bind 127.0.0.1
port $REDIS_PORT
requirepass "$redis_auth_escaped"
maxmemory-policy noeviction
daemonize yes
pidfile "$redis_pid"
logfile "$redis_log"
dir "$redis_root"
CONF
redis-server "$redis_conf"
if ! wait_for_port 127.0.0.1 "$REDIS_PORT" 30; then
echo "Redis did not start on 127.0.0.1:$REDIS_PORT"
exit 1
fi
}
ensure_clickhouse_running() {
ensure_clickhouse_binaries
local clickhouse_root="$CODEX_SERVICES_ROOT/clickhouse"
local clickhouse_data="$clickhouse_root/data"
local clickhouse_log="$clickhouse_root/clickhouse.log"
local clickhouse_err="$clickhouse_root/clickhouse.err.log"
local clickhouse_pid="$clickhouse_root/clickhouse.pid"
mkdir -p "$clickhouse_data"
if [ "${EUID:-$(id -u)}" -eq 0 ] && id -u clickhouse >/dev/null 2>&1; then
chown -R clickhouse:clickhouse "$clickhouse_root"
fi
if ! wait_for_http "http://127.0.0.1:$CLICKHOUSE_HTTP_PORT/ping" 1; then
clickhouse-server \
--daemon \
--config-file=/etc/clickhouse-server/config.xml \
--pid-file="$clickhouse_pid" \
--log-file="$clickhouse_log" \
--errorlog-file="$clickhouse_err" \
-- \
--path="$clickhouse_data" \
--http_port="$CLICKHOUSE_HTTP_PORT" \
--tcp_port="$CLICKHOUSE_NATIVE_PORT"
fi
if ! wait_for_http "http://127.0.0.1:$CLICKHOUSE_HTTP_PORT/ping" 45; then
echo "ClickHouse did not start on 127.0.0.1:$CLICKHOUSE_HTTP_PORT"
exit 1
fi
local clickhouse_password_sql
local clickhouse_user_identifier
clickhouse_password_sql="$(escape_sql_literal "$CLICKHOUSE_PASSWORD")"
clickhouse_user_identifier="$(escape_clickhouse_identifier "$CLICKHOUSE_USER")"
clickhouse-client --host 127.0.0.1 --port "$CLICKHOUSE_NATIVE_PORT" -q "CREATE USER IF NOT EXISTS $clickhouse_user_identifier IDENTIFIED WITH plaintext_password BY '$clickhouse_password_sql'"
clickhouse-client --host 127.0.0.1 --port "$CLICKHOUSE_NATIVE_PORT" -q "GRANT ALL ON *.* TO $clickhouse_user_identifier WITH GRANT OPTION"
}
ensure_minio_running() {
ensure_minio_binaries
local minio_root="$CODEX_SERVICES_ROOT/minio"
local minio_data="$minio_root/data"
local minio_log="$minio_root/minio.log"
local minio_pid="$minio_root/minio.pid"
local minio_already_running="false"
mkdir -p "$minio_data"
if wait_for_port 127.0.0.1 "$MINIO_API_PORT" 1; then
echo "MinIO already running on 127.0.0.1:$MINIO_API_PORT; skipping server start."
minio_already_running="true"
fi
if [ "$minio_already_running" != "true" ]; then
(
export MINIO_ROOT_USER MINIO_ROOT_PASSWORD
nohup minio server \
--address "127.0.0.1:$MINIO_API_PORT" \
--console-address "127.0.0.1:$MINIO_CONSOLE_PORT" \
"$minio_data" >"$minio_log" 2>&1 &
echo $! > "$minio_pid"
)
fi
if ! wait_for_port 127.0.0.1 "$MINIO_API_PORT" 45; then
echo "MinIO did not start on 127.0.0.1:$MINIO_API_PORT"
exit 1
fi
if ! mc alias set local "http://127.0.0.1:$MINIO_API_PORT" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; then
if [ "$minio_already_running" = "true" ]; then
echo "MinIO is running but credentials do not match MINIO_ROOT_USER/MINIO_ROOT_PASSWORD; skipping bucket reconciliation."
return 0
fi
echo "Failed to configure MinIO client alias for fresh MinIO startup."
exit 1
fi
if ! mc mb --ignore-existing local/langfuse >/dev/null 2>&1; then
if [ "$minio_already_running" = "true" ]; then
echo "Failed to reconcile MinIO bucket 'langfuse'; will retry on next run."
return 0
fi
echo "Failed to create MinIO bucket 'langfuse' after fresh startup."
exit 1
fi
}
ensure_cloud_dependencies() {
mkdir -p "$CODEX_SERVICES_ROOT"
ensure_postgres_running
ensure_redis_running
ensure_clickhouse_running
ensure_minio_running
echo "Cloud dependencies are installed and running:"
echo "- PostgreSQL on 127.0.0.1:$POSTGRES_PORT"
echo "- Redis on 127.0.0.1:$REDIS_PORT"
echo "- ClickHouse HTTP on 127.0.0.1:$CLICKHOUSE_HTTP_PORT, native on 127.0.0.1:$CLICKHOUSE_NATIVE_PORT"
echo "- MinIO API on 127.0.0.1:$MINIO_API_PORT, console on 127.0.0.1:$MINIO_CONSOLE_PORT"
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v corepack >/dev/null 2>&1; then
echo "corepack is required. Use a Codex base environment with Node.js 24 support."
exit 1
fi
corepack enable
corepack prepare pnpm@10.33.0 --activate
# shellcheck source=/dev/null
source "$(dirname "${BASH_SOURCE[0]}")/cloud_services.sh"
ensure_cloud_dependencies
pnpm install --frozen-lockfile
# Keep generated Prisma artifacts aligned after dependency or schema updates.
pnpm run db:generate
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
ensure_env_file() {
local target_path="$1"
local fallback_path="$2"
if [ -f "$target_path" ]; then
return 0
fi
cp "$fallback_path" "$target_path"
}
if ! command -v corepack >/dev/null 2>&1; then
echo "corepack is required. Use a Codex base environment with Node.js 24 support."
exit 1
fi
corepack enable
corepack prepare pnpm@10.33.0 --activate
ensure_env_file .env .env.dev.example
ensure_env_file .env.test .env.test.example
# shellcheck source=/dev/null
source "$(dirname "${BASH_SOURCE[0]}")/cloud_services.sh"
ensure_cloud_dependencies
pnpm install --frozen-lockfile
# Install Chromium into the default user-level Playwright cache so frontend
# browser review works on first bootstrap.
pnpm run playwright:install
# Generate the shared Prisma client explicitly in the current worktree before
# the workspace-wide db:generate task, which may be satisfied by Turbo cache.
pnpm --filter=shared run db:generate
# Prisma client generation is needed for typecheck/build tasks in Codex.
pnpm run db:generate
+19 -7
View File
@@ -7,7 +7,7 @@ RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat busybox
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS build-base
# Pin turbo to avoid nondeterministic prune output from future patch releases.
RUN npm install turbo@2.8.20 --global
RUN npm install turbo@2.9.5 --global
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -17,7 +17,21 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
# Remove build-only package managers. npm stays here because the runner stage
# uses it to install prisma and optional dd-trace before removing it.
RUN rm -rf /usr/local/lib/node_modules/corepack && \
rm -f /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
rm -f /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg
FROM --platform=${BUILDPLATFORM} golang:1.24 AS migrate-builder
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 \
GOBIN=/out \
GOOS=${TARGETOS} \
GOARCH=${TARGETARCH}
# Build only the ClickHouse migrate CLI Langfuse uses at runtime.
# compile this ourselves instead of downloading the upstream release
# because prebuilt bins bundle many unused drivers and thus inherit CVEs
# eg.: https://github.com/golang-migrate/migrate/issues/1357
RUN /usr/local/go/bin/go install -trimpath -tags 'clickhouse' -ldflags='-s -w' \
github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
FROM --platform=${TARGETPLATFORM:-linux/amd64} build-base AS pruner
@@ -122,7 +136,7 @@ ARG GID=1001
RUN addgroup --system --gid ${GID} nodejs
RUN adduser --system --uid ${UID} nextjs
RUN npm install -g --no-package-lock --no-save prisma@6.17.1
RUN npm install -g --no-package-lock --no-save prisma@6.19.3
# Install dd-trace only if NEXT_PUBLIC_LANGFUSE_CLOUD_REGION is configured
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
@@ -130,13 +144,11 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
npm install --no-package-lock --no-save dd-trace@5.65.0; \
fi
# Runtime images do not need npm once explicit runtime tools are installed.
# npm is only used for the installs above; remove it from the final runtime image.
RUN rm -rf /usr/local/lib/node_modules/npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx
RUN MIGRATE_TARGET_ARCH=$(echo ${TARGETPLATFORM:-linux/amd64} | sed 's/\//-/g') && \
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
mv migrate /usr/bin/migrate
COPY --from=migrate-builder /out/migrate /usr/bin/migrate
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.165.0",
"version": "3.167.2",
"private": true,
"license": "MIT",
"engines": {
@@ -43,7 +43,7 @@
"@headlessui/tailwindcss": "0.2.2",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
"@langchain/core": "^1.1.34",
"@langchain/core": "^1.1.39",
"@langfuse/ee": "workspace:*",
"@langfuse/shared": "workspace:*",
"@lezer/highlight": "^1.2.3",
@@ -122,12 +122,12 @@
"ioredis": "^5.8.2",
"ip-address": "^9.0.5",
"json-schema-faker": "^0.5.9",
"langchain": "^1.2.15",
"langchain": "^1.3.0",
"langfuse": "3.38.4",
"lodash": "^4.17.23",
"lodash": "^4.18.1",
"lucide-react": "^0.552.0",
"nanoid": "^3.3.11",
"next": "16.2.1",
"next": "16.2.3",
"next-auth": "^4.24.13",
"next-query-params": "^5.1.0",
"next-themes": "^0.4.6",
@@ -192,10 +192,10 @@
"@typescript/native-preview": "7.0.0-dev.20260122.3",
"dotenv-cli": "^7.4.2",
"eslint": "^9.39.2",
"eslint-config-next": "16.2.1",
"eslint-config-next": "16.2.3",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"node-mocks-http": "^1.14.1",
"node-mocks-http": "^1.17.2",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.2",
+18 -1
View File
@@ -5639,6 +5639,19 @@ paths:
required: true
schema:
type: string
- name: fields
in: query
description: >-
Comma-separated list of fields to include in the response. Available
field groups: 'core' (always included), 'io' (input, output,
metadata), 'scores', 'observations', 'metrics'. If not specified,
all fields are returned. Example: 'core,scores,metrics'. Note:
Excluded 'observations' or 'scores' fields return empty arrays;
excluded 'metrics' returns -1 for 'totalCost' and 'latency'.
required: false
schema:
type: string
nullable: true
responses:
'200':
description: ''
@@ -6312,7 +6325,11 @@ components:
$ref: '#/components/schemas/BlobStorageIntegrationType'
bucketName:
type: string
description: Name of the storage bucket
description: >-
Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid
Azure container name (3-63 chars, lowercase letters, numbers, and
hyphens only, must start and end with a letter or number, no
consecutive hyphens).
endpoint:
type: string
nullable: true
@@ -445,6 +445,24 @@ describe("Blob Storage Integrations API", () => {
);
});
it("should reject invalid Azure container names", async () => {
const azureConfig = {
...validBlobStorageConfig,
projectId: testProject1Id,
type: "AZURE_BLOB_STORAGE" as const,
endpoint: "https://myaccount.blob.core.windows.net",
bucketName: "Feedback N8N Bot",
};
const result = await makeAPICall(
"PUT",
"/api/public/integrations/blob-storage",
azureConfig,
createBasicAuthHeader(testApiKey, testApiSecretKey),
);
expect(result.status).toBe(400);
});
it("should handle export modes with dates", async () => {
const customDateConfig = {
...validBlobStorageConfig,
@@ -320,21 +320,17 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
).toBe(true);
});
it("should return 404 when getting an ARCHIVED dataset item by id", async () => {
it("should return archived dataset item when getting by id", async () => {
const datasetName = `dataset-archived-by-id-${v4()}`;
// Create dataset
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{
name: datasetName,
},
{ name: datasetName },
auth,
);
// Create an archived dataset item
const archivedItem = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
@@ -350,16 +346,29 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
expect(archivedItem.status).toBe(200);
expect(archivedItem.body.status).toBe("ARCHIVED");
// Try to get the archived item by id - should return 404
const getArchivedItem = await makeAPICall(
const getArchivedItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
`/api/public/dataset-items/archived-item-by-id`,
undefined,
auth,
);
expect(getArchivedItem.status).toBe(404);
expect(getArchivedItem.status).toBe(200);
expect(getArchivedItem.body.id).toBe("archived-item-by-id");
expect(getArchivedItem.body.status).toBe("ARCHIVED");
});
it("should return active dataset item when getting by id", async () => {
const datasetName = `dataset-active-by-id-${v4()}`;
await makeZodVerifiedAPICall(
PostDatasetsV1Response,
"POST",
"/api/public/datasets",
{ name: datasetName },
auth,
);
// Create an active item to verify GET still works for active items
const activeItem = await makeZodVerifiedAPICall(
PostDatasetItemsV1Response,
"POST",
@@ -374,7 +383,6 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
);
expect(activeItem.status).toBe(200);
// Get the active item by id - should succeed
const getActiveItem = await makeZodVerifiedAPICall(
GetDatasetItemV1Response,
"GET",
@@ -1,6 +1,7 @@
import {
CTEQueryBuilder,
EventsAggregationQueryBuilder,
EventsQueryBuilder,
} from "@langfuse/shared/src/server";
describe("CTEQueryBuilder", () => {
@@ -121,3 +122,27 @@ describe("CTEQueryBuilder", () => {
expect(params.param3).toBe("value3");
});
});
describe("EventsQueryBuilder", () => {
it("should allow list queries to omit tool payload columns", () => {
const slimQuery = new EventsQueryBuilder({
projectId: "test-project",
})
.selectFieldSet("baseWithoutTools", "calculated")
.buildWithParams().query;
const defaultQuery = new EventsQueryBuilder({
projectId: "test-project",
})
.selectFieldSet("base", "calculated")
.buildWithParams().query;
expect(slimQuery).not.toContain('e.tool_definitions as "tool_definitions"');
expect(slimQuery).not.toContain('e.tool_calls as "tool_calls"');
expect(slimQuery).not.toContain('e.tool_call_names as "tool_call_names"');
expect(defaultQuery).toContain('e.tool_definitions as "tool_definitions"');
expect(defaultQuery).toContain('e.tool_calls as "tool_calls"');
expect(defaultQuery).toContain('e.tool_call_names as "tool_call_names"');
});
});
@@ -1,12 +1,25 @@
/** @jest-environment node */
jest.mock("@langfuse/shared/src/server", () => {
const actual = jest.requireActual("@langfuse/shared/src/server");
return {
...actual,
fetchLLMCompletion: jest.fn(),
};
});
import type { Session } from "next-auth";
import { LLMAdapter } from "@langfuse/shared";
import { prisma } from "@langfuse/shared/src/db";
import { appRouter } from "@/src/server/api/root";
import { createInnerTRPCContext } from "@/src/server/api/trpc";
import { decrypt } from "@langfuse/shared/encryption";
import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
import { decrypt, encrypt } from "@langfuse/shared/encryption";
import {
createOrgProjectAndApiKey,
fetchLLMCompletion,
} from "@langfuse/shared/src/server";
const mockFetchLLMCompletion = jest.mocked(fetchLLMCompletion);
describe("llmApiKey.all RPC", () => {
let projectId: string;
@@ -49,6 +62,7 @@ describe("llmApiKey.all RPC", () => {
const setup = await createOrgProjectAndApiKey();
projectId = setup.projectId;
orgId = setup.orgId;
mockFetchLLMCompletion.mockReset().mockResolvedValue({});
session = {
expires: "1",
@@ -126,6 +140,18 @@ describe("llmApiKey.all RPC", () => {
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
});
it("should block creating an llm api key with a localhost base URL", async () => {
await expect(
caller.llmApiKey.create({
projectId,
secretKey: "test-secret",
provider: "openai",
adapter: LLMAdapter.OpenAI,
baseURL: "http://localhost:11434/v1",
}),
).rejects.toThrow("Invalid base URL: Blocked hostname detected");
});
it("should create and get an llm api key", async () => {
const secret = "test-secret";
const provider = "openai";
@@ -216,6 +242,141 @@ describe("llmApiKey.all RPC", () => {
).rejects.toThrow("User does not have access to this resource or action");
});
it("should block testUpdate when the base URL changes without a new secret key", async () => {
await caller.llmApiKey.create({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-original",
baseURL: "https://api.openai.com/v1",
});
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
where: {
projectId,
provider: "openai",
},
});
const result = await caller.llmApiKey.testUpdate({
id: existingKey.id,
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
baseURL: "https://attacker.example.com/v1",
});
expect(result).toEqual({
success: false,
error: "Secret key is required when changing the base URL",
});
expect(mockFetchLLMCompletion).not.toHaveBeenCalled();
});
it("should allow testing an existing connection with an unchanged localhost base URL", async () => {
const connection = await prisma.llmApiKeys.create({
data: {
projectId,
provider: "local-ollama",
adapter: LLMAdapter.OpenAI,
secretKey: encrypt("sk-existing"),
displaySecretKey: "...ting",
baseURL: "http://localhost:11434/v1",
customModels: ["llama3.1"],
withDefaultModels: true,
},
});
const result = await caller.llmApiKey.testUpdate({
id: connection.id,
projectId,
provider: "local-ollama",
adapter: LLMAdapter.OpenAI,
});
expect(result).toEqual({ success: true });
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
});
it("should allow testUpdate without a new secret key when the base URL is unchanged", async () => {
const existingExtraHeaders = {
Authorization: "Bearer stored-token",
"X-Custom-Header": "stored-value",
};
await caller.llmApiKey.create({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-original",
baseURL: "https://api.openai.com/v1",
extraHeaders: existingExtraHeaders,
});
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
where: {
projectId,
provider: "openai",
},
});
const result = await caller.llmApiKey.testUpdate({
id: existingKey.id,
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
baseURL: "https://api.openai.com/v1",
});
expect(result).toEqual({ success: true });
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
expect(llmConnection.baseURL).toBe("https://api.openai.com/v1");
expect(decrypt(llmConnection.secretKey)).toBe("sk-original");
expect(JSON.parse(decrypt(llmConnection.extraHeaders))).toEqual(
existingExtraHeaders,
);
});
it("should allow testUpdate when the base URL changes and a new secret key is provided", async () => {
const existingExtraHeaders = {
Authorization: "Bearer stored-token",
"X-Custom-Header": "stored-value",
};
await caller.llmApiKey.create({
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-original",
baseURL: "https://api.openai.com/v1",
extraHeaders: existingExtraHeaders,
});
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
where: {
projectId,
provider: "openai",
},
});
const result = await caller.llmApiKey.testUpdate({
id: existingKey.id,
projectId,
provider: "openai",
adapter: LLMAdapter.OpenAI,
secretKey: "sk-rotated",
baseURL: "https://new-endpoint.example.com/v1",
});
expect(result).toEqual({ success: true });
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
expect(llmConnection.baseURL).toBe("https://new-endpoint.example.com/v1");
expect(decrypt(llmConnection.secretKey)).toBe("sk-rotated");
expect(llmConnection.extraHeaders).toBeUndefined();
});
it("should create and update an llm api key", async () => {
const secret = "test-secret";
const provider = "openai";
@@ -354,6 +354,25 @@ describe("/api/public/llm-connections API Endpoints", () => {
expect(response.body.extraHeaderKeys).toEqual([]);
});
it("should reject creating a connection with a localhost baseURL", async () => {
const response = await makeAPICall(
"PUT",
"/api/public/llm-connections",
{
provider: generateUniqueProvider("local-openai"),
adapter: LLMAdapter.OpenAI,
secretKey: "sk-local",
baseURL: "http://localhost:11434/v1",
},
auth,
);
expect(response.status).toBe(400);
expect(response.body.message).toBe(
"Invalid baseURL: Blocked hostname detected",
);
});
it("should update existing connection (upsert)", async () => {
const existingProvider = generateUniqueProvider("existing-provider");
@@ -69,7 +69,7 @@ const setupTriggerAndAction = async (projectId: string) => {
id: v4(),
projectId: projectId,
eventSource: "prompt",
eventActions: ["updated"],
eventActions: [],
filter: [],
status: "ACTIVE",
},
@@ -4,6 +4,7 @@ import {
createTrace,
createSessionScore,
getScoresByIds,
getScoreById,
} from "@langfuse/shared/src/server";
import {
createObservationsCh,
@@ -1304,4 +1305,121 @@ describe("/api/public/scores API Endpoint", () => {
});
});
});
describe("Bearer auth (public key only)", () => {
it("should create a score via POST /api/public/scores with Bearer public key", async () => {
const { projectId, publicKey } = await createOrgProjectAndApiKey();
const traceId = v4();
const trace = createTrace({ id: traceId, project_id: projectId });
await createTracesCh([trace]);
const scoreId = v4();
const response = await makeAPICall(
"POST",
"/api/public/scores",
{
id: scoreId,
traceId,
name: "feedback",
value: 1,
},
`Bearer ${publicKey}`,
);
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("id", scoreId);
await waitForExpect(async () => {
const score = await getScoreById({ projectId, scoreId });
expect(score).toBeDefined();
expect(score!.id).toBe(scoreId);
expect(score!.traceId).toBe(traceId);
expect(score!.name).toBe("feedback");
expect(score!.value).toBe(1);
});
});
it("should reject GET /api/public/scores with Bearer public key", async () => {
const { publicKey } = await createOrgProjectAndApiKey();
const response = await makeAPICall(
"GET",
"/api/public/scores",
undefined,
`Bearer ${publicKey}`,
);
expect(response.status).toBe(401);
});
it("should reject GET /api/public/scores/:scoreId with Bearer public key", async () => {
const { publicKey } = await createOrgProjectAndApiKey();
const response = await makeAPICall(
"GET",
`/api/public/scores/${v4()}`,
undefined,
`Bearer ${publicKey}`,
);
expect(response.status).toBe(401);
});
it("should reject DELETE /api/public/scores/:scoreId with Bearer public key", async () => {
const { publicKey } = await createOrgProjectAndApiKey();
const response = await makeAPICall(
"DELETE",
`/api/public/scores/${v4()}`,
undefined,
`Bearer ${publicKey}`,
);
expect(response.status).toBe(401);
});
it("should reject POST /api/public/scores with invalid Bearer token", async () => {
const response = await makeAPICall(
"POST",
"/api/public/scores",
{
traceId: v4(),
name: "feedback",
value: 1,
},
`Bearer pk-invalid-key-that-does-not-exist`,
);
expect(response.status).toBe(401);
});
it("should reject Bearer public key on non-scores endpoints", async () => {
const { publicKey } = await createOrgProjectAndApiKey();
const [tracesRes, observationsRes, sessionsRes] = await Promise.all([
makeAPICall(
"GET",
"/api/public/traces",
undefined,
`Bearer ${publicKey}`,
),
makeAPICall(
"GET",
"/api/public/observations",
undefined,
`Bearer ${publicKey}`,
),
makeAPICall(
"GET",
"/api/public/sessions",
undefined,
`Bearer ${publicKey}`,
),
]);
expect(tracesRes.status).toBe(401);
expect(observationsRes.status).toBe(401);
expect(sessionsRes.status).toBe(401);
});
});
});
@@ -0,0 +1,138 @@
import { signupSchema } from "@/src/features/auth/lib/signupSchema";
describe("signupSchema name validation", () => {
const validBaseInput = {
email: "test@example.com",
password: "P@ssw0rd!",
};
it("accepts names with accented letters", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "André",
});
expect(result.success).toBe(true);
});
it("accepts names with hyphens", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "Smith-Jones",
});
expect(result.success).toBe(true);
});
it("accepts names with apostrophes", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "O'Brien",
});
expect(result.success).toBe(true);
});
it("accepts names with periods", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "Dr. Smith",
});
expect(result.success).toBe(true);
});
it("rejects names longer than 100 characters", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "a".repeat(101),
});
expect(result.success).toBe(false);
});
it("accepts names with smart/curly apostrophes (U+2019)", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "O\u2019Brien",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe("O'Brien");
}
});
it("accepts names with left single quotation mark (U+2018)", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "O\u2018Brien",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe("O'Brien");
}
});
it("rejects punctuation-only names", () => {
for (const name of ["---", "...", "'''"]) {
const result = signupSchema.safeParse({
...validBaseInput,
name,
});
expect(result.success).toBe(false);
}
});
it("rejects whitespace-only names", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: " ",
});
expect(result.success).toBe(false);
});
it("rejects names with disallowed punctuation", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "André!",
});
expect(result.success).toBe(false);
});
it("rejects names with a leading combining mark", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "\u0301André",
});
expect(result.success).toBe(false);
});
it("rejects names consisting only of combining marks", () => {
const result = signupSchema.safeParse({
...validBaseInput,
name: "\u0301\u0302\u0303",
});
expect(result.success).toBe(false);
});
it("accepts NFD-decomposed names after NFC normalization", () => {
// "é" decomposed as e + combining acute accent
const result = signupSchema.safeParse({
...validBaseInput,
name: "Andre\u0301",
});
expect(result.success).toBe(true);
if (result.success) {
// NFC normalization should merge the combining mark
expect(result.data.name).toBe("André");
}
});
});
@@ -79,6 +79,7 @@ describe("Slack Integration", () => {
getWebClientForProject: jest.fn(),
sendMessage: jest.fn(),
getChannels: jest.fn(),
getChannelInfo: jest.fn(),
validateClient: jest.fn(),
deleteIntegration: jest.fn(),
};
@@ -196,7 +197,10 @@ describe("Slack Integration", () => {
},
];
mockSlackService.getChannels.mockResolvedValue(mockChannels);
mockSlackService.getChannels.mockResolvedValue({
channels: mockChannels,
hasPrivateChannelAccess: true,
});
const { caller, project } = await prepare();
@@ -217,6 +221,7 @@ describe("Slack Integration", () => {
expect(result).toMatchObject({
channels: mockChannels,
hasPrivateChannelAccess: true,
teamId: "T123456",
teamName: "Test Team",
});
@@ -306,6 +311,71 @@ describe("Slack Integration", () => {
expect(JSON.stringify(result)).not.toContain("xoxb-test-token");
});
it("should resolve channel info for manually-typed channel names", async () => {
const mockClient = { auth: { test: jest.fn() } };
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
mockSlackService.sendMessage.mockResolvedValue({
messageTs: "1234567890.123456",
channel: "C999888",
});
mockSlackService.getChannelInfo.mockResolvedValue({
id: "C999888",
name: "general",
isPrivate: false,
});
const { caller, project } = await prepare();
await prisma.slackIntegration.create({
data: {
projectId: project.id,
teamId: "T123456",
teamName: "Test Team",
botToken: encrypt("xoxb-test-token"),
botUserId: "U123456",
},
});
const result = await caller.slack.sendTestMessage({
projectId: project.id,
channelId: "#general",
channelName: "general",
});
expect(result).toMatchObject({
success: true,
channel: "C999888",
channelInfo: {
id: "C999888",
name: "general",
isPrivate: false,
},
});
expect(mockSlackService.getChannelInfo).toHaveBeenCalledWith(
mockClient,
"C999888",
);
// Verify audit log records the resolved channel ID, not the #-prefixed input
const auditLogEntry = await prisma.auditLog.findFirst({
where: {
projectId: project.id,
resourceType: "slackIntegration",
action: "create",
},
orderBy: { createdAt: "desc" },
});
expect(auditLogEntry).toBeDefined();
const afterData = auditLogEntry?.after
? JSON.parse(auditLogEntry.after)
: null;
expect(afterData).toMatchObject({
channelId: "C999888",
});
});
it("should create audit log entry", async () => {
const mockClient = { auth: { test: jest.fn() } };
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
@@ -501,9 +571,12 @@ describe("Slack Integration", () => {
it("should NEVER expose raw bot tokens in any API response", async () => {
mockSlackService.validateClient.mockResolvedValue(true);
mockSlackService.getChannels.mockResolvedValue([
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
]);
mockSlackService.getChannels.mockResolvedValue({
channels: [
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
],
hasPrivateChannelAccess: true,
});
mockSlackService.sendMessage.mockResolvedValue({
messageTs: "1234567890.123456",
channel: "C123456",
@@ -220,6 +220,139 @@ describe("/api/public/traces API Endpoint", () => {
);
});
it("should fetch a trace with core-only fields when fields=core", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-core-only",
user_id: "user-1",
project_id: projectId,
metadata: { key: "value" },
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.02, output: 0.03, total: 0.05 },
});
const score = createTraceScore({
trace_id: traceId,
project_id: projectId,
name: "test-score",
value: 0.8,
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
await createScoresCh([score]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=core`,
);
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).toBeNull();
expect(trace.body.output).toBeNull();
expect(trace.body.metadata).toEqual({});
expect(trace.body.observations).toEqual([]);
expect(trace.body.scores).toEqual([]);
expect(trace.body.totalCost).toBe(-1);
expect(trace.body.latency).toBe(-1);
});
it("should fetch a trace with core,scores,metrics fields", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-with-scores-metrics",
project_id: projectId,
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.02, output: 0.03, total: 0.05 },
input: "observation input",
output: "observation output",
});
const score = createTraceScore({
trace_id: traceId,
project_id: projectId,
name: "test-score",
value: 0.8,
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
await createScoresCh([score]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=core,scores,metrics`,
);
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).toBeNull();
expect(trace.body.output).toBeNull();
expect(trace.body.observations).toEqual([]);
expect(trace.body.scores).toHaveLength(1);
expect(trace.body.totalCost).toBe(0.05);
expect(trace.body.latency).toBeCloseTo(1);
});
it("should return all fields when fields param contains only invalid groups", async () => {
const traceId = randomUUID();
const createdTrace = createTrace({
id: traceId,
name: "trace-invalid-fields",
project_id: projectId,
input: JSON.stringify({ prompt: "test" }),
output: JSON.stringify({ response: "test response" }),
metadata: { key: "value" },
});
const observation = createObservation({
trace_id: traceId,
project_id: projectId,
name: "test-observation",
end_time: new Date().getTime(),
start_time: new Date().getTime() - 1000,
cost_details: { input: 0.01, output: 0.02, total: 0.03 },
});
await createTracesCh([createdTrace]);
await createObservationsCh([observation]);
const trace = await makeZodVerifiedAPICall(
GetTraceV1Response,
"GET",
`/api/public/traces/${traceId}?fields=invalid_group,also_invalid`,
);
// All invalid fields should fall back to returning all field groups
expect(trace.body.id).toBe(traceId);
expect(trace.body.input).not.toBeNull();
expect(trace.body.output).not.toBeNull();
expect(trace.body.observations).toHaveLength(1);
expect(trace.body.totalCost).toBeGreaterThanOrEqual(0);
expect(trace.body.latency).toBeGreaterThanOrEqual(0);
});
it("should fetch all traces", async () => {
const timestamp = new Date();
const createdTrace = createTrace({
@@ -0,0 +1,126 @@
import { ChatMessageRole, ChatMessageType } from "@langfuse/shared";
import { createMessageSearchController } from "./controller";
describe("message search controller", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
const commitQuery = (
controller: ReturnType<typeof createMessageSearchController>,
query: string,
) => {
controller.setQueryInput(query);
jest.runAllTimers();
return controller.getSnapshot().matches;
};
it("finds all occurrences of a query", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
},
{
id: "message-2",
type: ChatMessageType.User,
role: ChatMessageRole.User,
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ",
},
]);
expect(commitQuery(controller, "Lorem")).toEqual([
expect.objectContaining({
messageId: "message-1",
from: 0,
to: 5,
}),
expect.objectContaining({
messageId: "message-2",
from: 0,
to: 5,
}),
]);
expect(commitQuery(controller, "dolor")).toEqual([
expect.objectContaining({
messageId: "message-1",
from: 12,
to: 17,
}),
expect.objectContaining({
messageId: "message-1",
from: 103,
to: 108,
}),
expect.objectContaining({
messageId: "message-2",
from: 12,
to: 17,
}),
expect.objectContaining({
messageId: "message-2",
from: 103,
to: 108,
}),
]);
});
// Regression test for https://github.com/langfuse/langfuse/issues/13002
it("matches fullwidth and halfwidth variants consistently", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content:
"Langfuse is an LLM observability platform. Langfuse is also great.",
},
]);
expect(commitQuery(controller, "Langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
expect(commitQuery(controller, "Langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
expect(commitQuery(controller, "langfuse")).toEqual([
expect.objectContaining({ from: 0, to: 8 }),
expect.objectContaining({ from: 43, to: 51 }),
]);
});
// Regression test for https://github.com/langfuse/langfuse/issues/13002
it("returns original document offsets for compatibility character matches", () => {
const controller = createMessageSearchController(["page-1"]);
controller.registerPageMessages("page-1", [
{
id: "message-1",
type: ChatMessageType.System,
role: ChatMessageRole.System,
content: "高さ180㌢の棚",
},
]);
expect(commitQuery(controller, "センチ")).toEqual([
expect.objectContaining({ from: 5, to: 6 }),
]);
});
});
@@ -3,11 +3,14 @@
import capitalize from "lodash/capitalize";
import { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { ChatMessageType, type ChatMessageWithId } from "@langfuse/shared";
import { EditorState } from "@codemirror/state";
import { SearchQuery } from "@codemirror/search";
import { type RefObject } from "react";
import {
applyCodeMirrorSearchQuery,
selectCodeMirrorRange,
unsetActiveSearchMarkCodeMirrorRange,
setActiveSearchMarkCodeMirrorRange,
} from "@/src/components/editor";
export type MessageSearchMatch = {
@@ -136,7 +139,11 @@ function buildMatches(state: MessageSearchState) {
return [];
}
const lowerQuery = searchQuery.toLocaleLowerCase();
const codeMirrorSearchQuery = new SearchQuery({
search: searchQuery,
caseSensitive: false,
literal: true,
});
const allMatches: MessageSearchMatch[] = [];
for (const [pageIndex, pageId] of state.pageIds.entries()) {
@@ -151,10 +158,13 @@ function buildMatches(state: MessageSearchState) {
continue;
}
const lowerText = text.toLocaleLowerCase();
let from = lowerText.indexOf(lowerQuery);
const cursor = codeMirrorSearchQuery.getCursor(
EditorState.create({ doc: text }),
);
let match = cursor.next();
while (from !== -1) {
while (!match.done) {
const { from, to } = match.value;
const label = getMessageSearchLabel(message, messageIndex);
const pageLabel = state.getPageLabel?.(pageId, pageIndex);
const matchWithoutKey = {
@@ -163,7 +173,7 @@ function buildMatches(state: MessageSearchState) {
label,
locationLabel: pageLabel ? `${pageLabel} · ${label}` : label,
from,
to: from + searchQuery.length,
to,
text,
};
@@ -172,10 +182,7 @@ function buildMatches(state: MessageSearchState) {
...matchWithoutKey,
});
from = lowerText.indexOf(
lowerQuery,
from + Math.max(1, lowerQuery.length),
);
match = cursor.next();
}
}
}
@@ -269,17 +276,33 @@ export function createMessageSearchController(
inline: "center",
});
const messageTarget = messageTargets.get(
getMessageTargetKey(activeMatch.pageId, activeMatch.messageId),
let activeMessageTarget: MessageSearchMessageTarget | null = null;
const inactiveMessageTargets: MessageSearchMessageTarget[] = [];
const activeMessageTargetKey = getMessageTargetKey(
activeMatch.pageId,
activeMatch.messageId,
);
messageTarget?.rowRef.current?.scrollIntoView({
for (const [key, target] of messageTargets.entries()) {
if (key === activeMessageTargetKey) {
activeMessageTarget = target;
} else {
inactiveMessageTargets.push(target);
}
}
for (const target of inactiveMessageTargets) {
unsetActiveSearchMarkCodeMirrorRange(target?.editorRef);
}
activeMessageTarget?.rowRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
selectCodeMirrorRange(messageTarget?.editorRef, {
setActiveSearchMarkCodeMirrorRange(activeMessageTarget?.editorRef, {
from: activeMatch.from,
to: activeMatch.to,
});
@@ -318,7 +341,7 @@ export function createMessageSearchController(
const refreshSearchResults = (shouldSyncEditors: boolean) => {
const activeMatchChanged = recomputeMatches();
if (shouldSyncEditors) {
if (shouldSyncEditors || activeMatchChanged) {
syncEditorsToQuery();
}
@@ -21,7 +21,6 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
openRequestCount,
queryInput,
matches,
activeMatch,
activeMatchIndex,
openSearch,
closeSearch,
@@ -109,9 +108,6 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
onClick={nextMatch}
disabled={matches.length === 0}
/>
<div className="text-muted-foreground hidden max-w-48 truncate px-1 text-xs lg:block">
{activeMatch?.locationLabel ?? "No matches"}
</div>
<IconButton icon={X} label="Close search" onClick={closeSearch} />
</div>
);
+185 -14
View File
@@ -6,7 +6,7 @@ import CodeMirror, {
ViewPlugin,
type ViewUpdate,
} from "@uiw/react-codemirror";
import { RangeSetBuilder } from "@codemirror/state";
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
import { SearchQuery, search, setSearchQuery } from "@codemirror/search";
import { json, jsonParseLinter } from "@codemirror/lang-json";
import { linter, type Diagnostic } from "@codemirror/lint";
@@ -165,6 +165,142 @@ const bidiSupport = [
),
];
// Add custom search highlight decoration using the same class names as the default search match decorations
// See: https://github.com/codemirror/search/blob/36e8f21e070d471fcbe2e2f338ef4e647b492ba8/src/search.ts#L396
const searchMatchMark = Decoration.mark({
class: "cm-searchMatch",
});
const selectedSearchMatchMark = Decoration.mark({
class: "cm-searchMatch cm-searchMatch-selected",
});
const setSearchHighlightMarks = StateEffect.define<
{
from: number;
to: number;
}[]
>({
map: (ranges, change) =>
ranges.map(({ from, to }) => ({
from: change.mapPos(from),
to: change.mapPos(to),
})),
});
const setSelectedSearchHighlightMark = StateEffect.define<{
from: number;
to: number;
}>({
map: ({ from, to }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
}),
});
const unsetSelectedSearchHighlightMark = StateEffect.define({});
const searchHighlightingSupport = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decos, tr) {
decos = decos.map(tr.changes);
for (const effect of tr.effects) {
if (effect.is(setSearchHighlightMarks)) {
// Remove all existing search highlights
decos = decos.update({
filter: (from, to, decoration) => {
return !decoration.spec.class?.includes("cm-searchMatch");
},
});
decos = decos.update({
add: effect.value.map(({ from, to }) =>
searchMatchMark.range(from, to),
),
});
}
if (effect.is(unsetSelectedSearchHighlightMark)) {
let selectedRange: { from: number; to: number } | null = null;
decos = decos.update({
filter: (from, to, decoration) => {
if (decoration.spec.class?.includes("cm-searchMatch-selected")) {
selectedRange = { from, to };
return false;
}
return true;
},
});
// Reassign the value and cast it because typescript infers it to be always null,
// not recognizing it to be assigned in the filter above.
selectedRange = selectedRange as {
from: number;
to: number;
} | null;
if (selectedRange) {
decos = decos.update({
add: [searchMatchMark.range(selectedRange.from, selectedRange.to)],
});
}
}
if (effect.is(setSelectedSearchHighlightMark)) {
// Remove normal search match mark from the selected range,
// otherwise there will _both_ a normal and selected highlight on the active match.
decos = decos.update({
filter: (from, to, decoration) => {
if (from === effect.value.from && to === effect.value.to) {
return !decoration.spec.class?.includes("cm-searchMatch");
}
return true;
},
});
let previousSelectedRange: { from: number; to: number } | null = null;
// Make the existing selected search highlight a normal search highlight
decos = decos.update({
filter: (from, to, decoration) => {
if (decoration.spec.class?.includes("cm-searchMatch-selected")) {
previousSelectedRange = { from, to };
return false;
}
return true;
},
});
// Reassign the value and cast it because typescript infers it to be always null,
// not recognizing it to be assigned in the filter above.
previousSelectedRange = previousSelectedRange as {
from: number;
to: number;
} | null;
decos = decos.update({
add: [
...(previousSelectedRange
? [
searchMatchMark.range(
previousSelectedRange.from,
previousSelectedRange.to,
),
]
: []),
selectedSearchMatchMark.range(effect.value.from, effect.value.to),
].toSorted((a, b) => a.from - b.from),
});
}
}
return decos;
},
provide: (f) => EditorView.decorations.from(f),
});
export function applyCodeMirrorSearchQuery(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
searchValue: string,
@@ -174,18 +310,30 @@ export function applyCodeMirrorSearchQuery(
return;
}
const searchQuery = new SearchQuery({
search: searchValue,
caseSensitive: false,
literal: true,
});
view.dispatch({
effects: setSearchQuery.of(
new SearchQuery({
search: searchValue,
caseSensitive: false,
literal: true,
}),
),
effects: setSearchQuery.of(searchQuery),
});
const cursor = searchQuery.getCursor(view.state);
const matchRanges: { from: number; to: number }[] = [];
let current = cursor.next();
while (!current.done) {
matchRanges.push(current.value);
current = cursor.next();
}
view.dispatch({
effects: setSearchHighlightMarks.of(matchRanges),
});
}
export function selectCodeMirrorRange(
export function setActiveSearchMarkCodeMirrorRange(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
range: { from: number; to: number } | null,
) {
@@ -195,11 +343,23 @@ export function selectCodeMirrorRange(
}
view.dispatch({
selection: {
anchor: range.from,
head: range.to,
},
scrollIntoView: true,
effects: [
setSelectedSearchHighlightMark.of(range),
EditorView.scrollIntoView(range.from),
],
});
}
export function unsetActiveSearchMarkCodeMirrorRange(
editorRef: RefObject<ReactCodeMirrorRef | null> | undefined,
) {
const view = editorRef?.current?.view;
if (!view) {
return;
}
view.dispatch({
effects: unsetSelectedSearchHighlightMark.of(null),
});
}
@@ -268,6 +428,7 @@ export function CodeMirrorEditor({
}}
lang={mode === "json" ? "json" : undefined}
extensions={[
searchHighlightingSupport,
search(),
// RTL/bidi support - must be early for proper line decoration
...bidiSupport,
@@ -277,6 +438,16 @@ export function CodeMirrorEditor({
outline: "none",
},
}),
// Update search match highlight styles
EditorView.theme({
".cm-searchMatch.cm-searchMatch": {
backgroundColor: "hsl(var(--find-match-background))",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "hsl(var(--find-match-selected-background))",
color: "hsl(var(--find-match-selected-foreground))",
},
}),
// Hide gutter when lineNumbers is false
// Fix missing gutter border
...(!lineNumbers
@@ -11,6 +11,7 @@ interface ResizableDesktopLayoutProps {
mainContent: ReactNode;
sidebarContent: ReactNode;
open: boolean;
showHandle?: boolean;
defaultMainSize?: number;
defaultSidebarSize?: number;
minMainSize?: number;
@@ -36,6 +37,7 @@ export function ResizableDesktopLayout({
mainContent,
sidebarContent,
open,
showHandle = true,
defaultMainSize = 70,
defaultSidebarSize = 30,
minMainSize = 30,
@@ -108,7 +110,9 @@ export function ResizableDesktopLayout({
{sidebarContent}
</ResizablePanel>
)}
{sidebarPosition === "left" && open && <ResizableHandle withHandle />}
{sidebarPosition === "left" && open && showHandle && (
<ResizableHandle withHandle />
)}
<ResizablePanel
id={MAIN_PANEL_ID}
defaultSize={`${defaultMainSize}%`}
@@ -121,7 +125,9 @@ export function ResizableDesktopLayout({
{mainContent}
</div>
</ResizablePanel>
{sidebarPosition === "right" && open && <ResizableHandle withHandle />}
{sidebarPosition === "right" && open && showHandle && (
<ResizableHandle withHandle />
)}
{sidebarPosition === "right" && (
<ResizablePanel
id={SIDEBAR_PANEL_ID}
@@ -59,6 +59,7 @@ export function ResizableContent({ children }: PropsWithChildren) {
mainContent={children}
sidebarContent={<SupportDrawer />}
open={open}
showHandle={false}
defaultMainSize={70}
defaultSidebarSize={30}
minMainSize={30}
+9 -1
View File
@@ -77,6 +77,7 @@ interface DataTableProps<TData, TValue> {
tableName: string;
getRowClassName?: (row: TData) => string;
topAlignCells?: boolean;
cellPadding?: "compact" | "comfortable";
}
export interface AsyncTableData<T> {
@@ -165,6 +166,7 @@ export function DataTable<TData extends object, TValue>({
tableName,
getRowClassName,
topAlignCells = false,
cellPadding = "compact",
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights);
@@ -416,6 +418,7 @@ export function DataTable<TData extends object, TValue>({
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
getRowClassName={getRowClassName}
topAlignCells={topAlignCells}
cellPadding={cellPadding}
tableSnapshot={{
columnVisibility,
columnOrder,
@@ -434,6 +437,7 @@ export function DataTable<TData extends object, TValue>({
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
getRowClassName={getRowClassName}
topAlignCells={topAlignCells}
cellPadding={cellPadding}
/>
)}
</Table>
@@ -480,6 +484,7 @@ interface TableBodyComponentProps<TData> {
onRowClick?: (row: TData, event?: React.MouseEvent) => void;
getRowClassName?: (row: TData) => string;
topAlignCells?: boolean;
cellPadding?: "compact" | "comfortable";
tableSnapshot?: {
columnVisibility?: VisibilityState;
columnOrder?: ColumnOrderState;
@@ -533,6 +538,7 @@ function TableBodyComponent<TData>({
onRowClick,
getRowClassName,
topAlignCells = false,
cellPadding = "compact",
}: TableBodyComponentProps<TData>) {
return (
<TableBody>
@@ -562,7 +568,8 @@ function TableBodyComponent<TData>({
<TableCell
key={cell.id}
className={cn(
"overflow-hidden border-b px-1 text-xs first:pl-2",
"overflow-hidden border-b text-xs first:pl-2",
cellPadding === "comfortable" ? "p-1" : "px-1",
isSmallRowHeight && "whitespace-nowrap",
getPinningClasses(cell.column),
)}
@@ -654,6 +661,7 @@ const MemoizedTableBody = React.memo(TableBodyComponent, (prev, next) => {
if (prev.data.isLoading !== next.data.isLoading) return false;
if (prev.rowheighttw !== next.rowheighttw) return false;
if (prev.rowHeight !== next.rowHeight) return false;
if (prev.cellPadding !== next.cellPadding) return false;
// Then do more expensive deep equality checks
if (
@@ -358,6 +358,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
cellPadding="comfortable"
onRowClick={(row) => {
router.push(`/project/${projectId}/settings/models/${row.modelId}`);
}}
@@ -284,6 +284,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
rowHeight={rowHeight}
cellPadding="comfortable"
className="gap-2"
/>
</SettingsTableCard>
+27 -17
View File
@@ -13,8 +13,9 @@ import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
import { useSidebarFilterState } from "@/src/features/filters/hooks/useSidebarFilterState";
import {
scoreFilterConfig,
getScoreFilterConfig,
SCORE_COLUMN_TO_BACKEND_KEY,
type ScoresTableHiddenColumn,
} from "@/src/features/filters/config/scores-config";
import { DEFAULT_SIDEBAR_IMPLICIT_ENVIRONMENT_CONFIG } from "@/src/features/filters/constants/internal-environments";
import { transformFiltersForBackend } from "@/src/features/filters/lib/filter-transform";
@@ -80,6 +81,16 @@ export type ScoresTableRow = {
executionTraceId?: string;
};
export type ScoresTableProps = {
projectId: string;
userId?: string;
traceId?: string;
observationId?: string;
hiddenColumns?: ScoresTableHiddenColumn[];
localStorageSuffix?: string;
disableUrlPersistence?: boolean;
};
function createFilterState(
userFilterState: FilterState,
omittedFilters: Record<string, string>[],
@@ -104,16 +115,15 @@ export default function ScoresTable({
hiddenColumns = [],
localStorageSuffix = "",
disableUrlPersistence = false,
}: {
projectId: string;
userId?: string;
traceId?: string;
observationId?: string;
omittedFilter?: string[];
hiddenColumns?: string[];
localStorageSuffix?: string;
disableUrlPersistence?: boolean;
}) {
}: ScoresTableProps) {
const scoresFilterConfig = useMemo(
() => getScoreFilterConfig(hiddenColumns),
[hiddenColumns],
);
const hiddenColumnSet = useMemo(
() => new Set<string>(hiddenColumns),
[hiddenColumns],
);
const { isBetaEnabled } = useV4Beta();
// In v4beta, scores must exclusively use events-backed endpoints (no traces-table route).
const useEventsBackedScores = isBetaEnabled;
@@ -288,7 +298,7 @@ export default function ScoresTable({
);
const queryFilter = useSidebarFilterState(
scoreFilterConfig,
scoresFilterConfig,
newFilterOptions,
{
loading: filterOptions.isPending || environmentFilterOptions.isPending,
@@ -322,7 +332,7 @@ export default function ScoresTable({
const backendFilterState = transformFiltersForBackend(
filterState,
SCORE_COLUMN_TO_BACKEND_KEY,
scoreFilterConfig.columnDefinitions,
scoresFilterConfig.columnDefinitions,
);
const getCountPayload = {
@@ -714,7 +724,7 @@ export default function ScoresTable({
];
const columns = rawColumns.filter(
(c) => !!c.id && !hiddenColumns.includes(c.id),
(c) => !!c.id && !hiddenColumnSet.has(c.id),
);
const [columnVisibility, setColumnVisibility] =
@@ -819,15 +829,15 @@ export default function ScoresTable({
},
validationContext: {
columns,
filterColumnDefinition: scoreFilterConfig.columnDefinitions,
filterColumnDefinition: scoresFilterConfig.columnDefinitions,
},
currentFilterState: queryFilter.explicitFilterState,
});
return (
<DataTableControlsProvider
tableName={scoreFilterConfig.tableName}
defaultSidebarCollapsed={scoreFilterConfig.defaultSidebarCollapsed}
tableName={scoresFilterConfig.tableName}
defaultSidebarCollapsed={scoresFilterConfig.defaultSidebarCollapsed}
>
<div className="flex h-full w-full flex-col">
{/* Toolbar spanning full width */}
@@ -594,12 +594,12 @@ export const ObservationPreview = ({
<ScoresTable
projectId={projectId}
traceId={traceId}
omittedFilter={["Observation ID"]}
observationId={preloadedObservation.id}
hiddenColumns={[
"traceId",
"observationId",
"traceName",
"traceTags",
"jobConfigurationId",
"userId",
]}
+7 -2
View File
@@ -583,9 +583,14 @@ export const TracePreview = ({
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden pr-3 md:flex-1">
<ScoresTable
projectId={trace.projectId}
omittedFilter={["Trace ID"]}
traceId={trace.id}
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
hiddenColumns={[
"traceId",
"traceName",
"traceTags",
"jobConfigurationId",
"userId",
]}
localStorageSuffix="TracePreview"
disableUrlPersistence
/>
@@ -129,6 +129,7 @@ export function ObservationDetailView({
setJsonViewPreference,
jsonBetaEnabled,
setJsonBetaEnabled,
isPeekMode,
} = useViewPreferences();
// Map jsonViewPreference to currentView format expected by child components
@@ -476,11 +477,12 @@ export function ObservationDetailView({
"traceId",
"observationId",
"traceName",
"traceTags",
"jobConfigurationId",
"userId",
]}
localStorageSuffix="ObservationPreview"
disableUrlPersistence
disableUrlPersistence={isPeekMode}
/>
</div>
</TabsBarContent>
@@ -93,6 +93,7 @@ export function TraceDetailView({
setJsonViewPreference,
jsonBetaEnabled,
setJsonBetaEnabled,
isPeekMode,
} = useViewPreferences();
// Map jsonViewPreference to currentView format expected by child components
@@ -417,11 +418,16 @@ export function TraceDetailView({
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden pr-3">
<ScoresTable
projectId={projectId}
omittedFilter={["Trace ID"]}
traceId={trace.id}
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
hiddenColumns={[
"traceId",
"traceName",
"traceTags",
"jobConfigurationId",
"userId",
]}
localStorageSuffix="TracePreview"
disableUrlPersistence
disableUrlPersistence={isPeekMode}
/>
</div>
</TabsBarContent>
+7 -4
View File
@@ -2,6 +2,8 @@ import * as React from "react";
import { cn } from "@/src/utils/tailwind";
type TableDensity = "compact" | "comfortable";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
@@ -74,7 +76,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"bg-background text-muted-foreground relative h-10 border-b px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
"bg-background text-muted-foreground relative h-10 border-b px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
@@ -84,12 +86,13 @@ TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
React.TdHTMLAttributes<HTMLTableCellElement> & { density?: TableDensity }
>(({ className, density = "compact", ...props }, ref) => (
<td
ref={ref}
className={cn(
"h-full px-2 py-0 align-middle [&:has([role=checkbox])]:pr-0",
"h-full align-middle [&:has([role=checkbox])]:pr-0",
density === "comfortable" ? "p-2" : "px-2 py-0",
"border-b [:last-child_>_&]:border-b-0",
className,
)}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.165.0";
export const VERSION = "v3.167.2";
@@ -187,6 +187,7 @@ export function AuditLogsTable(props: AuditLogsTableProps) {
state: paginationState,
}}
rowHeight={rowHeight}
cellPadding="comfortable"
/>
</SettingsTableCard>
</>
+3
View File
@@ -399,6 +399,7 @@ export const env = createEnv({
.enum(["true", "false"])
.default("false"),
LANGFUSE_API_TRACES_DEFAULT_FIELDS: z.string().optional(),
LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS: z.string().optional(),
// Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS: z
@@ -781,6 +782,8 @@ export const env = createEnv({
process.env.LANGFUSE_API_TRACES_REJECT_NO_DATE_RANGE,
LANGFUSE_API_TRACES_DEFAULT_FIELDS:
process.env.LANGFUSE_API_TRACES_DEFAULT_FIELDS,
LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS:
process.env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS,
// Events table migration
LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS:
process.env.LANGFUSE_ENABLE_EVENTS_TABLE_OBSERVATIONS,
@@ -17,6 +17,7 @@ import { TraceAnnotationProcessor } from "./processors/TraceAnnotationProcessor"
import { SessionAnnotationProcessor } from "./processors/SessionAnnotationProcessor";
import { ObjectNotFoundCard } from "@/src/components/ui/object-not-found-card";
import { useV4Beta } from "@/src/features/events/hooks/useV4Beta";
import { useSession } from "next-auth/react";
export const AnnotationQueueItemPage: React.FC<{
annotationQueueId: string;
@@ -25,6 +26,8 @@ export const AnnotationQueueItemPage: React.FC<{
queryItemId?: string;
}> = ({ annotationQueueId, projectId, view, queryItemId }) => {
const router = useRouter();
const { status: sessionStatus } = useSession();
const sessionLoaded = sessionStatus !== "loading";
const { isBetaEnabled } = useV4Beta();
const isSingleItem = router.query.singleItem === "true";
const [nextItemData, setNextItemData] = useState<
@@ -42,7 +45,7 @@ export const AnnotationQueueItemPage: React.FC<{
const seenItemData = api.annotationQueueItems.byId.useQuery(
{ projectId, itemId: itemId as string, isBetaEnabled },
{ enabled: !!itemId, refetchOnMount: false },
{ enabled: !!itemId && sessionLoaded, refetchOnMount: false },
);
const fetchAndLockNextMutation =
@@ -51,18 +54,19 @@ export const AnnotationQueueItemPage: React.FC<{
// Effects
useEffect(() => {
async function fetchNextItem() {
if (!itemId && !isSingleItem) {
if (!itemId && !isSingleItem && sessionLoaded) {
const nextItem = await fetchAndLockNextMutation.mutateAsync({
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
}
fetchNextItem();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [sessionLoaded]);
const { configs } = useAnnotationQueueData({ annotationQueueId, projectId });
const unseenPendingItemCount =
@@ -88,6 +92,7 @@ export const AnnotationQueueItemPage: React.FC<{
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
@@ -144,7 +149,8 @@ export const AnnotationQueueItemPage: React.FC<{
(seenItemData.isPending && itemId) ||
(fetchAndLockNextMutation.isPending && !itemId) ||
unseenPendingItemCount.isPending ||
objectData.isLoading
objectData.isLoading ||
(!sessionLoaded && !isSingleItem)
) {
return <Skeleton className="h-full w-full" />;
}
@@ -165,6 +171,7 @@ export const AnnotationQueueItemPage: React.FC<{
queueId: annotationQueueId,
projectId,
seenItemIds,
isBetaEnabled,
});
setNextItemData(nextItem);
}
@@ -13,7 +13,11 @@ import {
optionalPaginationZod,
Prisma,
} from "@langfuse/shared";
import { getObservationById, logger } from "@langfuse/shared/src/server";
import {
getObservationById,
getObservationByIdFromEventsTable,
logger,
} from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -464,78 +468,73 @@ export const queueRouter = createTRPCRouter({
queueId: z.string(),
projectId: z.string(),
seenItemIds: z.array(z.string()),
isBetaEnabled: z.boolean().optional().default(false),
}),
)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoProjectAccess({
session: ctx.session,
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "annotationQueues:CUD",
});
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const item = await ctx.prisma.annotationQueueItem.findFirst({
where: {
queueId: input.queueId,
projectId: input.projectId,
scope: "annotationQueues:CUD",
});
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const item = await ctx.prisma.annotationQueueItem.findFirst({
where: {
queueId: input.queueId,
projectId: input.projectId,
status: AnnotationQueueStatus.PENDING,
OR: [
{ lockedAt: null },
{ lockedAt: { lt: fiveMinutesAgo } },
{ lockedByUserId: ctx.session.user.id },
],
NOT: {
id: { in: input.seenItemIds },
},
status: AnnotationQueueStatus.PENDING,
OR: [
{ lockedAt: null },
{ lockedAt: { lt: fiveMinutesAgo } },
{ lockedByUserId: ctx.session.user.id },
],
NOT: {
id: { in: input.seenItemIds },
},
orderBy: {
createdAt: "asc",
},
});
},
orderBy: {
createdAt: "asc",
},
});
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
if (!item) return null;
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
if (!item) return null;
const updatedItem = await ctx.prisma.annotationQueueItem.update({
where: {
id: item.id,
projectId: input.projectId,
},
data: {
lockedAt: now,
lockedByUserId: ctx.session.user.id,
},
});
const updatedItem = await ctx.prisma.annotationQueueItem.update({
where: {
id: item.id,
projectId: input.projectId,
},
data: {
lockedAt: now,
lockedByUserId: ctx.session.user.id,
},
});
const inflatedUpdatedItem = {
...updatedItem,
lockedByUser: { name: ctx.session.user.name },
const inflatedUpdatedItem = {
...updatedItem,
lockedByUser: { name: ctx.session.user.name },
};
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
const clickhouseObservation = input.isBetaEnabled
? await getObservationByIdFromEventsTable({
id: item.objectId,
projectId: input.projectId,
})
: await getObservationById({
id: item.objectId,
projectId: input.projectId,
});
return {
...inflatedUpdatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
const clickhouseObservation = await getObservationById({
id: item.objectId,
projectId: input.projectId,
});
return {
...inflatedUpdatedItem,
parentTraceId: clickhouseObservation?.traceId,
};
}
return inflatedUpdatedItem;
} catch (error) {
logger.error(error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Fetching and locking next annotation queue item failed.",
});
}
return inflatedUpdatedItem;
}),
});
+14 -5
View File
@@ -17,12 +17,21 @@ export const passwordSchema = z
"Please choose a secure password by combining letters, numbers, and special characters.",
});
export const signupSchema = z.object({
name: StringNoHTMLNonEmpty.refine((value) => noUrlCheck(value), {
export const nameSchema = StringNoHTMLNonEmpty.max(
100,
"Name must be at most 100 characters",
)
.transform((value) => value.normalize("NFC").replace(/[\u2018\u2019]/g, "'"))
.refine((value) => noUrlCheck(value), {
message: "Input should not contain a URL",
}).refine((value) => /^[a-zA-Z0-9\s]+$/.test(value), {
message: "Name can only contain letters, numbers, and spaces",
}),
})
.refine((value) => /^\p{L}[\p{L}\p{M}\p{N}\s.'\-]*$/u.test(value), {
message:
"Name must start with a letter and can only contain letters, numbers, spaces, hyphens, apostrophes, and periods",
});
export const signupSchema = z.object({
name: nameSchema,
email: z.string().email(),
password: passwordSchema,
referralSource: z.string().optional(),
@@ -31,8 +31,12 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
disabled,
projectId,
}) => {
const initialChannelId = form.getValues("slack.channelId") as string;
const initialChannelName = form.getValues("slack.channelName") as string;
const [selectedChannel, setSelectedChannel] = useState<SlackChannel | null>(
null,
initialChannelId && initialChannelName
? { id: initialChannelId, name: initialChannelName }
: null,
);
// Get Slack integration status
@@ -88,6 +92,7 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
<ChannelSelector
projectId={projectId}
selectedChannelId={field.value}
selectedChannel={selectedChannel}
onChannelSelect={handleChannelSelect}
disabled={disabled}
placeholder="Select a channel"
@@ -96,7 +101,12 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
</div>
</FormControl>
<FormDescription>
Select the Slack channel where notifications will be sent.
Select the Slack channel where notifications will be sent. For
private channels, invite the app first with{" "}
<code className="bg-muted rounded px-1 py-0.5">
/invite @Langfuse
</code>{" "}
in that channel.
</FormDescription>
<FormMessage />
</FormItem>
@@ -120,6 +130,23 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
disabled={disabled}
size="sm"
buttonText="Test Channel"
onSuccess={(channelInfo) => {
form.setValue("slack.channelId", channelInfo.id);
form.setValue(
"slack.channelName",
channelInfo.name ?? selectedChannel?.name ?? "",
);
setSelectedChannel((prev) =>
prev
? {
...prev,
id: channelInfo.id,
name: channelInfo.name ?? prev.name,
isPrivate: channelInfo.isPrivate ?? prev.isPrivate,
}
: prev,
);
}}
/>
<p className="text-muted-foreground text-sm">
Test this channel to verify the bot can send messages.
@@ -6,7 +6,8 @@ import {
createTRPCRouter,
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { blobStorageIntegrationFormSchema } from "@/src/features/blobstorage-integration/types";
import { blobStorageIntegrationFormSchemaBase } from "@/src/features/blobstorage-integration/types";
import { validateAzureContainerName } from "@/src/features/blobstorage-integration/validation";
import { upsertBlobStorageIntegration } from "@/src/features/blobstorage-integration/service";
import { TRPCError } from "@trpc/server";
import {
@@ -56,7 +57,11 @@ export const blobStorageIntegrationRouter = createTRPCRouter({
}),
update: protectedProjectProcedure
.input(blobStorageIntegrationFormSchema.extend({ projectId: z.string() }))
.input(
blobStorageIntegrationFormSchemaBase
.extend({ projectId: z.string() })
.superRefine(validateAzureContainerName),
)
.mutation(async ({ input, ctx }) => {
try {
throwIfNoProjectAccess({
@@ -5,8 +5,9 @@ import {
BlobStorageExportMode,
AnalyticsIntegrationExportSource,
} from "@langfuse/shared";
import { validateAzureContainerName } from "@/src/features/blobstorage-integration/validation";
export const blobStorageIntegrationFormSchema = z.object({
export const blobStorageIntegrationFormSchemaBase = z.object({
type: z.enum(BlobStorageIntegrationType),
bucketName: z.string().min(1, { message: "Bucket name is required" }),
endpoint: z.string().url().optional().nullable(),
@@ -36,6 +37,9 @@ export const blobStorageIntegrationFormSchema = z.object({
compressed: z.boolean().default(true),
});
export const blobStorageIntegrationFormSchema =
blobStorageIntegrationFormSchemaBase.superRefine(validateAzureContainerName);
export type BlobStorageIntegrationFormSchema = z.infer<
typeof blobStorageIntegrationFormSchema
>;
@@ -0,0 +1,83 @@
import {
AZURE_CONTAINER_NAME_REGEX,
validateAzureContainerName,
} from "./validation";
import { z } from "zod";
describe("AZURE_CONTAINER_NAME_REGEX", () => {
const valid = [
"abc",
"my-container",
"a1b2c3",
"123",
"a-b",
"a".repeat(63),
"container-name-1",
];
const invalid = [
"ab", // too short
"a", // too short
"a".repeat(64), // too long
"ABC", // uppercase
"My-Container", // mixed case
"-abc", // starts with hyphen
"abc-", // ends with hyphen
"my--container", // consecutive hyphens
"has space", // spaces
"has.dot", // dots
"has/slash", // slashes
"Feedback N8N Bot", // the original issue
"", // empty
];
it.each(valid)("accepts valid name: %s", (name) => {
expect(AZURE_CONTAINER_NAME_REGEX.test(name)).toBe(true);
});
it.each(invalid)("rejects invalid name: %s", (name) => {
expect(AZURE_CONTAINER_NAME_REGEX.test(name)).toBe(false);
});
});
describe("validateAzureContainerName via schema", () => {
const schema = z
.object({ type: z.string(), bucketName: z.string() })
.superRefine(validateAzureContainerName);
it("rejects invalid Azure container name", () => {
const result = schema.safeParse({
type: "AZURE_BLOB_STORAGE",
bucketName: "Feedback N8N Bot",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(["bucketName"]);
}
});
it("allows invalid container name for S3 type", () => {
const result = schema.safeParse({
type: "S3",
bucketName: "Feedback N8N Bot",
});
expect(result.success).toBe(true);
});
it("allows valid Azure container name", () => {
const result = schema.safeParse({
type: "AZURE_BLOB_STORAGE",
bucketName: "valid-container",
});
expect(result.success).toBe(true);
});
it("skips Azure validation when bucketName is empty", () => {
const result = schema.safeParse({
type: "AZURE_BLOB_STORAGE",
bucketName: "",
});
// Should pass superRefine (empty guard), letting .min(1) handle it upstream
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,31 @@
import { z } from "zod";
/**
* Azure container names must be 3-63 characters, lowercase letters, numbers,
* and hyphens only. Must start and end with a letter or number. No consecutive
* hyphens.
*
* @see https://learn.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#container-names
*/
export const AZURE_CONTAINER_NAME_REGEX =
/^[a-z0-9](?!.*--)[a-z0-9-]{1,61}[a-z0-9]$/;
export const AZURE_CONTAINER_NAME_ERROR =
"Azure container names must be 3-63 characters, lowercase letters, numbers, and hyphens only. Must start and end with a letter or number, no consecutive hyphens.";
export function validateAzureContainerName(
data: { type: string; bucketName: string },
ctx: z.RefinementCtx,
) {
if (!data.bucketName) return;
if (
data.type === "AZURE_BLOB_STORAGE" &&
!AZURE_CONTAINER_NAME_REGEX.test(data.bucketName)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: AZURE_CONTAINER_NAME_ERROR,
path: ["bucketName"],
});
}
}
@@ -102,11 +102,20 @@ export function SelectDashboardDialog({
selectedDashboardId === d.id ? "bg-muted" : ""
}`}
>
<TableCell className="font-medium">{d.name}</TableCell>
<TableCell className="truncate" title={d.description}>
<TableCell
density="comfortable"
className="font-medium"
>
{d.name}
</TableCell>
<TableCell
density="comfortable"
className="truncate"
title={d.description}
>
{d.description}
</TableCell>
<TableCell>
<TableCell density="comfortable">
{new Date(d.updatedAt).toLocaleString()}
</TableCell>
</TableRow>
@@ -24,6 +24,7 @@ import { useIsAuthenticatedAndProjectMember } from "@/src/features/auth/hooks";
import { parseJsonPrioritised } from "@langfuse/shared";
import { ActionButton } from "@/src/components/ActionButton";
import { type MetadataDomainClient } from "@/src/utils/clientSideDomainTypes";
import { type Prisma } from "@langfuse/shared";
/**
* Component for creating a new dataset item from an existing object.
@@ -39,22 +40,30 @@ export const NewDatasetItemFromExistingObject = (props: {
traceId?: string;
observationId?: string;
fromDatasetId?: string;
input: string | null;
output: string | null;
input: Prisma.JsonValue | null;
output: Prisma.JsonValue | null;
metadata: MetadataDomainClient;
isCopyItem?: boolean;
buttonVariant?: ButtonProps["variant"];
size?: ButtonProps["size"];
}) => {
const parsedInput =
props.input && typeof props.input === "string"
? (parseJsonPrioritised(props.input) ?? null)
: null;
const normalizePrefillValue = (
value: Prisma.JsonValue | null,
): Prisma.JsonValue | null => {
if (value === null || value === undefined) {
return null;
}
const parsedOutput =
props.output && typeof props.output === "string"
? (parseJsonPrioritised(props.output) ?? null)
: null;
if (typeof value === "string") {
const parsed = parseJsonPrioritised(value);
return parsed !== undefined ? parsed : value;
}
return value;
};
const parsedInput = normalizePrefillValue(props.input);
const parsedOutput = normalizePrefillValue(props.output);
const [isFormOpen, setIsFormOpen] = useState(false);
const isAuthenticatedAndProjectMember = useIsAuthenticatedAndProjectMember(
@@ -301,11 +301,29 @@ export default function ExperimentsTable({
header: getExperimentsColumnName("experimentDatasetId"),
size: 150,
cell: ({ row }) => {
const key: string | undefined = row.getValue("datasetId");
const value = filterOptions.experimentDatasetId?.find(
(d) => d.value === key,
const datasetId: string | undefined = row.getValue("datasetId");
const datasetName = filterOptions.experimentDatasetId?.find(
(d) => d.value === datasetId,
)?.displayValue;
return value ? <TableIdOrName value={value} /> : undefined;
if (!datasetId || !datasetName) {
return undefined;
}
return (
<Link
href={`/project/${projectId}/datasets/${encodeURIComponent(datasetId)}`}
target="_blank"
rel="noopener noreferrer"
>
<Badge
variant="secondary"
className="hover:bg-secondary/80 max-w-full cursor-pointer"
>
{datasetName}
</Badge>
</Link>
);
},
},
{
@@ -0,0 +1,22 @@
import { getScoreFilterConfig } from "./scores-config";
describe("getScoreFilterConfig", () => {
it("omits sidebar facets for hidden score columns", () => {
const config = getScoreFilterConfig([
"traceId",
"traceName",
"observationId",
"traceTags",
]);
expect(config.facets.map((facet) => facet.column)).not.toContain("traceId");
expect(config.facets.map((facet) => facet.column)).not.toContain(
"traceName",
);
expect(config.facets.map((facet) => facet.column)).not.toContain(
"observationId",
);
expect(config.facets.map((facet) => facet.column)).not.toContain("tags");
expect(config.facets.map((facet) => facet.column)).toContain("userId");
});
});
@@ -8,6 +8,20 @@ export const SCORE_COLUMN_TO_BACKEND_KEY: ColumnToBackendKeyMap = {
tags: "trace_tags",
};
export type ScoresTableHiddenColumn =
| "traceId"
| "traceName"
| "observationId"
| "jobConfigurationId"
| "userId"
| "traceTags";
const SCORES_HIDDEN_COLUMN_TO_FILTER_COLUMN: Partial<
Record<ScoresTableHiddenColumn, string>
> = {
traceTags: "tags",
};
export const scoreFilterConfig: FilterConfig = {
tableName: "scores",
@@ -83,3 +97,26 @@ export const scoreFilterConfig: FilterConfig = {
},
],
};
export function getScoreFilterConfig(
hiddenColumns: ScoresTableHiddenColumn[] = [],
): FilterConfig {
if (hiddenColumns.length === 0) {
return scoreFilterConfig;
}
const hiddenColumnSet = new Set<string>(
hiddenColumns.map(
(column) => SCORES_HIDDEN_COLUMN_TO_FILTER_COLUMN[column] ?? column,
),
);
return {
...scoreFilterConfig,
defaultExpanded: scoreFilterConfig.defaultExpanded?.filter(
(column) => !hiddenColumnSet.has(column),
),
facets: scoreFilterConfig.facets.filter(
(facet) => !hiddenColumnSet.has(facet.column),
),
};
}
+75 -12
View File
@@ -33,6 +33,7 @@ import {
blockEvaluatorConfigsInTx,
EvaluatorBlockSource,
finalizeBlockedEvaluatorConfigBlocks,
validateLlmConnectionBaseURL,
} from "@langfuse/shared/src/server";
import { env } from "@/src/env.mjs";
import { TRPCError } from "@trpc/server";
@@ -130,6 +131,27 @@ async function testLLMConnection(
}
}
async function validateBaseURLForWrite(params: {
baseURL?: string | null;
errorPrefix?: string;
}): Promise<void> {
if (!params.baseURL) {
return;
}
try {
await validateLlmConnectionBaseURL(params.baseURL);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? `${params.errorPrefix ?? "Invalid base URL"}: ${error.message}`
: (params.errorPrefix ?? "Invalid base URL"),
});
}
}
export const llmApiKeyRouter = createTRPCRouter({
create: protectedProjectProcedureWithoutTracing
.input(CreateLlmApiKey)
@@ -141,6 +163,10 @@ export const llmApiKeyRouter = createTRPCRouter({
scope: "llmApiKeys:create",
});
await validateBaseURLForWrite({
baseURL: input.baseURL,
});
// Validate that default credentials sentinel is only allowed for Bedrock/VertexAI in self-hosted deployments
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
@@ -406,6 +432,17 @@ export const llmApiKeyRouter = createTRPCRouter({
scope: "llmApiKeys:create",
});
if (input.baseURL) {
try {
await validateLlmConnectionBaseURL(input.baseURL);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Invalid base URL",
};
}
}
return testLLMConnection({
adapter: input.adapter,
provider: input.provider,
@@ -442,25 +479,41 @@ export const llmApiKeyRouter = createTRPCRouter({
});
}
const decryptedSecretKey =
input.secretKey !== undefined &&
input.secretKey !== "" &&
input.secretKey !== null
? input.secretKey
: decrypt(existingKey.secretKey);
const hasNewSecretKey =
typeof input.secretKey === "string" && input.secretKey.length > 0;
const baseURL = input.baseURL ?? existingKey.baseURL;
const isBaseURLChanged = baseURL !== existingKey.baseURL;
if (isBaseURLChanged && !hasNewSecretKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Secret key is required when changing the base URL",
});
}
if (input.baseURL && isBaseURLChanged) {
await validateLlmConnectionBaseURL(input.baseURL);
}
const secretKey = hasNewSecretKey
? (input.secretKey as string)
: decrypt(existingKey.secretKey);
// Merge existing key with provided input, giving priority to input
const secretKey = decryptedSecretKey;
const adapter = input.adapter ?? (existingKey.adapter as LLMAdapter);
const provider = input.provider ?? existingKey.provider;
const baseURL = input.baseURL ?? existingKey.baseURL;
const customModels = input.customModels ?? existingKey.customModels;
const config = input.config ?? existingKey.config;
// Never reuse stored headers across a destination change.
const extraHeaders =
input.extraHeaders ??
(existingKey.extraHeaders
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
: undefined);
input.extraHeaders !== undefined
? input.extraHeaders
: isBaseURLChanged
? undefined
: existingKey.extraHeaders
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
: undefined;
return testLLMConnection({
adapter,
@@ -519,6 +572,16 @@ export const llmApiKeyRouter = createTRPCRouter({
// Validate that default credentials sentinel is only allowed for Bedrock/VertexAI in self-hosted deployments
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const isBaseURLChanged =
input.baseURL !== undefined
? input.baseURL !== existingKey.baseURL
: false;
if (input.baseURL && isBaseURLChanged) {
await validateBaseURLForWrite({
baseURL: input.baseURL,
});
}
if (input.secretKey === BEDROCK_USE_DEFAULT_CREDENTIALS) {
if (isLangfuseCloud || input.adapter !== LLMAdapter.Bedrock) {
@@ -117,7 +117,11 @@ export function ApiKeyList(props: { entityId: string; scope: ApiKeyScope }) {
<TableBody className="text-muted-foreground">
{apiKeysQuery.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center">
<TableCell
density="comfortable"
colSpan={5}
className="text-center"
>
None
</TableCell>
</TableRow>
@@ -127,29 +131,32 @@ export function ApiKeyList(props: { entityId: string; scope: ApiKeyScope }) {
key={apiKey.id}
className="hover:bg-primary-foreground"
>
<TableCell className="hidden md:table-cell">
<TableCell
density="comfortable"
className="hidden md:table-cell"
>
{apiKey.createdAt.toLocaleDateString()}
</TableCell>
<TableCell>
<TableCell density="comfortable">
<ApiKeyNote
apiKey={apiKey}
entityId={entityId}
scope={scope}
/>
</TableCell>
<TableCell className="font-mono">
<TableCell density="comfortable" className="font-mono">
<CodeView
className="inline-block text-xs"
content={apiKey.publicKey}
/>
</TableCell>
<TableCell className="font-mono">
<TableCell density="comfortable" className="font-mono">
{apiKey.displaySecretKey}
</TableCell>
{/* <TableCell>
{apiKey.lastUsedAt?.toLocaleDateString() ?? "Never"}
</TableCell> */}
<TableCell>
<TableCell density="comfortable">
<DeleteApiKeyButton
entityId={entityId}
apiKeyId={apiKey.id}
@@ -93,7 +93,11 @@ export function LlmApiKeyList(props: { projectId: string }) {
<TableBody className="text-muted-foreground">
{apiKeys.data?.data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center">
<TableCell
density="comfortable"
colSpan={6}
className="text-center"
>
None
</TableCell>
</TableRow>
@@ -104,18 +108,28 @@ export function LlmApiKeyList(props: { projectId: string }) {
className="hover:bg-primary-foreground cursor-default"
onClick={() => setEditingKeyId(apiKey.id)}
>
<TableCell className="font-mono">{apiKey.provider}</TableCell>
<TableCell className="font-mono">{apiKey.adapter}</TableCell>
<TableCell className="max-w-md overflow-auto font-mono">
<TableCell density="comfortable" className="font-mono">
{apiKey.provider}
</TableCell>
<TableCell density="comfortable" className="font-mono">
{apiKey.adapter}
</TableCell>
<TableCell
density="comfortable"
className="max-w-md overflow-auto font-mono"
>
{apiKey.baseURL ?? "default"}
</TableCell>
<TableCell className="font-mono">
<TableCell density="comfortable" className="font-mono">
{apiKey.displaySecretKey}
</TableCell>
{hasExtraHeaderKeys ? (
<TableCell> {apiKey.extraHeaderKeys.join(", ")} </TableCell>
<TableCell density="comfortable">
{" "}
{apiKey.extraHeaderKeys.join(", ")}{" "}
</TableCell>
) : null}
<TableCell className="text-right">
<TableCell density="comfortable" className="text-right">
<div
className="flex justify-end space-x-2"
onClick={(e) => e.stopPropagation()}
@@ -6,6 +6,7 @@ import { prisma } from "@langfuse/shared/src/db";
import {
redis,
type AuthHeaderValidVerificationResult,
type ApiAccessLevel,
traceException,
logger,
} from "@langfuse/shared/src/server";
@@ -15,6 +16,9 @@ import { contextWithLangfuseProps } from "@langfuse/shared/src/server";
import * as opentelemetry from "@opentelemetry/api";
import { env } from "@/src/env.mjs";
/** Access levels that can be accepted by project-scoped API routes. */
type RouteAccessLevel = Exclude<ApiAccessLevel, "organization">;
type RouteConfig<
TQuery extends ZodType<any>,
TBody extends ZodType<any>,
@@ -40,30 +44,41 @@ type RouteConfig<
* @default false
*/
isAdminApiKeyAuthAllowed?: boolean;
/**
* Access levels accepted for this route. Defaults to ["project"] (Basic auth only).
* Set to ["project", "scores"] to also allow Bearer auth with a public key
* (which receives accessLevel "scores").
*/
allowedAccessLevels?: RouteAccessLevel[];
fn: (params: {
query: z.infer<TQuery>;
body: z.infer<TBody>;
req: NextApiRequest;
res: NextApiResponse;
auth: AuthHeaderValidVerificationResult & {
scope: { projectId: string; accessLevel: "project" };
scope: { projectId: string; accessLevel: RouteAccessLevel };
};
}) => Promise<z.infer<TResponse>>;
};
/**
* Verifies regular API key authentication using ApiAuthService.
* Verifies API key authentication (Basic or Bearer) using ApiAuthService.
*
* This function handles standard project API key authentication with Basic auth.
* Returns an auth scope object with project-level access.
* Delegates to ApiAuthService.verifyAuthHeaderAndReturnScope which handles
* both Basic auth (public + secret key) and Bearer auth (public key only).
* The caller controls which access levels are accepted via allowedAccessLevels.
*
* @param authHeader - The Authorization header from the request
* @returns An auth scope object with project-level access
* @param allowedAccessLevels - Access levels to accept (default: ["project"])
* @returns An auth scope object with the verified access level
* @throws Error with appropriate message if authentication fails
*/
async function verifyBasicAuth(authHeader: string | undefined): Promise<
async function verifyApiKeyAuth(
authHeader: string | undefined,
allowedAccessLevels: RouteAccessLevel[] = ["project"],
): Promise<
AuthHeaderValidVerificationResult & {
scope: { projectId: string; accessLevel: "project" };
scope: { projectId: string; accessLevel: RouteAccessLevel };
}
> {
const regularAuth = await new ApiAuthService(
@@ -75,10 +90,14 @@ async function verifyBasicAuth(authHeader: string | undefined): Promise<
throw { status: 401, message: regularAuth.error };
}
if (regularAuth.scope.accessLevel !== "project") {
if (
!(allowedAccessLevels as ApiAccessLevel[]).includes(
regularAuth.scope.accessLevel,
)
) {
throw {
status: 401,
message: "Access denied - need to use basic auth with secret key",
message: "Access denied - insufficient permissions for this endpoint",
};
}
@@ -91,7 +110,7 @@ async function verifyBasicAuth(authHeader: string | undefined): Promise<
}
return regularAuth as AuthHeaderValidVerificationResult & {
scope: { projectId: string; accessLevel: "project" };
scope: { projectId: string; accessLevel: RouteAccessLevel };
};
}
@@ -198,22 +217,25 @@ async function verifyAdminApiKeyAuth(req: NextApiRequest): Promise<
}
/**
* Verifies authentication for API routes with support for both basic and admin API key auth.
* Verifies authentication for API routes with support for both regular API key
* auth (Basic or Bearer) and admin API key auth.
*
* This is the main authentication entry point that delegates to either admin or basic auth
* based on the configuration and request headers.
* This is the main authentication entry point that delegates to either admin
* or regular API key auth based on the configuration and request headers.
*
* @param req - The Next.js API request
* @param isAdminApiKeyAuthAllowed - Whether to allow admin API key authentication
* @returns An auth scope object with project-level access
* @param allowedAccessLevels - Access levels to accept for regular API key auth
* @returns An auth scope object with the verified access level
* @throws Error with appropriate status code if authentication fails
*/
export async function verifyAuth(
req: NextApiRequest,
isAdminApiKeyAuthAllowed: boolean,
allowedAccessLevels: RouteAccessLevel[] = ["project"],
): Promise<
AuthHeaderValidVerificationResult & {
scope: { projectId: string; accessLevel: "project" };
scope: { projectId: string; accessLevel: RouteAccessLevel };
}
> {
if (isAdminApiKeyAuthAllowed) {
@@ -223,12 +245,15 @@ export async function verifyAuth(
// Admin auth succeeded
return adminAuth;
}
// Admin auth not attempted, fall back to basic auth
return await verifyBasicAuth(req.headers.authorization);
// Admin auth not attempted, fall back to regular API key auth
return await verifyApiKeyAuth(
req.headers.authorization,
allowedAccessLevels,
);
}
// Only basic auth is allowed
return await verifyBasicAuth(req.headers.authorization);
// Only regular API key auth is allowed
return await verifyApiKeyAuth(req.headers.authorization, allowedAccessLevels);
}
export const createAuthedProjectAPIRoute = <
@@ -240,14 +265,15 @@ export const createAuthedProjectAPIRoute = <
): ((req: NextApiRequest, res: NextApiResponse) => Promise<void>) => {
return async (req: NextApiRequest, res: NextApiResponse) => {
let auth: AuthHeaderValidVerificationResult & {
scope: { projectId: string; accessLevel: "project" };
scope: { projectId: string; accessLevel: RouteAccessLevel };
};
// Verify authentication (basic or admin API key)
// Verify authentication (API key or admin API key)
try {
auth = await verifyAuth(
req,
routeConfig.isAdminApiKeyAuthAllowed || false,
routeConfig.allowedAccessLevels || ["project"],
);
} catch (error: any) {
const statusCode = error.status || 401;
@@ -294,7 +320,7 @@ export const createAuthedProjectAPIRoute = <
req,
res,
auth: auth as AuthHeaderValidVerificationResult & {
scope: { projectId: string; accessLevel: "project" };
scope: { projectId: string; accessLevel: RouteAccessLevel };
},
});
@@ -1,4 +1,5 @@
import { z } from "zod";
import { validateAzureContainerName } from "@/src/features/blobstorage-integration/validation";
/**
* Enums
@@ -26,7 +27,7 @@ export const CreateBlobStorageIntegrationRequest = z
.object({
projectId: z.string(),
type: BlobStorageIntegrationType,
bucketName: z.string(),
bucketName: z.string().min(1),
endpoint: z.string().nullable().optional(),
region: z.string(),
accessKeyId: z.string().nullable().optional(),
@@ -57,7 +58,8 @@ export const CreateBlobStorageIntegrationRequest = z
"exportStartDate is required when exportMode is FROM_CUSTOM_DATE",
path: ["exportStartDate"],
},
);
)
.superRefine(validateAzureContainerName);
export const BlobStorageIntegrationResponse = z
.object({
+14 -1
View File
@@ -89,10 +89,11 @@ export const GetTracesV1Query = z.object({
.nullish()
.transform((v) => {
if (!v) return null;
return v
const parsed = v
.split(",")
.map((f) => f.trim())
.filter((f) => TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup));
return parsed.length > 0 ? parsed : null;
})
.pipe(z.array(z.enum(TRACE_FIELD_GROUPS)).nullable()),
useEventsTable: useEventsTableSchema,
@@ -125,6 +126,18 @@ export const PostTracesV1Response = z.object({ id: z.string() });
// GET /api/public/traces/{traceId}
export const GetTraceV1Query = z.object({
traceId: z.string(),
fields: z
.string()
.nullish()
.transform((v) => {
if (!v) return null;
const parsed = v
.split(",")
.map((f) => f.trim())
.filter((f) => TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup));
return parsed.length > 0 ? parsed : null;
})
.pipe(z.array(z.enum(TRACE_FIELD_GROUPS)).nullable()),
});
export const GetTraceV1Response = APIExtendedTrace.extend({
scores: z.array(APIScoreSchemaV1),
@@ -432,6 +432,7 @@ export function MembersTable({
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
cellPadding="comfortable"
/>
</SettingsTableCard>
) : (
@@ -464,6 +465,7 @@ export function MembersTable({
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
cellPadding="comfortable"
/>
)}
</>
+1 -1
View File
@@ -20,7 +20,7 @@
"https://staging.langfuse.com/api/public/slack/oauth"
],
"scopes": {
"bot": ["channels:read", "chat:write", "chat:write.public"]
"bot": ["channels:read", "groups:read", "chat:write", "chat:write.public"]
}
},
"settings": {
@@ -1,7 +1,12 @@
import React, { useState, useMemo } from "react";
import { RefreshCw, Search, Hash, Lock } from "lucide-react";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { RefreshCw, Search, Hash, Lock, AlertTriangle } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { Alert, AlertDescription } from "@/src/components/ui/alert";
import { Select, SelectTrigger, SelectValue } from "@/src/components/ui/select";
import {
Command,
@@ -11,23 +16,11 @@ import {
CommandItem,
CommandList,
} from "@/src/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/src/components/ui/popover";
import { Alert, AlertDescription } from "@/src/components/ui/alert";
import { useVirtualizer } from "@tanstack/react-virtual";
import { api } from "@/src/utils/api";
import { type SlackChannel } from "@langfuse/shared/src/server";
/**
* Represents a Slack channel
*/
export interface SlackChannel {
id: string;
name: string;
isPrivate: boolean;
isMember: boolean;
}
export type { SlackChannel };
/**
* Props for the ChannelSelector component
@@ -37,6 +30,8 @@ interface ChannelSelectorProps {
projectId: string;
/** Currently selected channel ID */
selectedChannelId?: string;
/** Full channel object for display when the ID isn't in the fetched list (e.g. manual entry) */
selectedChannel?: SlackChannel | null;
/** Callback when a channel is selected */
onChannelSelect: (channel: SlackChannel) => void;
/** Whether the component is disabled */
@@ -51,6 +46,8 @@ interface ChannelSelectorProps {
showRefreshButton?: boolean;
}
const ITEM_HEIGHT = 32;
/**
* A dropdown component for selecting Slack channels with search and filtering capabilities.
*
@@ -64,9 +61,11 @@ interface ChannelSelectorProps {
*
* The component uses a command palette style interface for better UX when dealing with
* many channels. It supports both keyboard navigation and mouse interaction.
* Items are virtualized with @tanstack/react-virtual to handle large channel lists (~5k).
*
* @param projectId - The project ID for the Slack integration
* @param selectedChannelId - Currently selected channel ID
* @param selectedChannel - Full channel object for display when the ID isn't in the fetched list (e.g. manual entry)
* @param onChannelSelect - Callback when a channel is selected
* @param disabled - Whether the component should be disabled
* @param placeholder - Placeholder text for the selector
@@ -77,6 +76,7 @@ interface ChannelSelectorProps {
export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
projectId,
selectedChannelId,
selectedChannel: selectedChannelProp,
onChannelSelect,
disabled = false,
placeholder = "Select a channel",
@@ -87,6 +87,9 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrollNode, setScrollNode] = useState<HTMLDivElement | null>(null);
const trimmedSearch = searchValue.trim();
const effectiveName = trimmedSearch.replace(/^#/, "");
// Get available channels
const {
@@ -130,36 +133,63 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
}
// Apply search filter
if (searchValue.trim()) {
const searchTerm = searchValue.toLowerCase().trim();
if (effectiveName) {
const searchTerm = effectiveName.toLowerCase();
channels = channels.filter((channel) =>
channel.name.toLowerCase().includes(searchTerm),
);
}
// Sort channels: public channels first, then private, then by name
return channels.sort((a, b) => {
return [...channels].sort((a, b) => {
if (a.isPrivate !== b.isPrivate) {
return a.isPrivate ? 1 : -1;
}
return a.name.localeCompare(b.name);
});
}, [channelsData?.channels, memberOnly, filterChannels, searchValue]);
}, [channelsData?.channels, memberOnly, filterChannels, effectiveName]);
// Get selected channel info
const virtualizer = useVirtualizer({
count: filteredChannels.length,
getScrollElement: () => scrollNode,
estimateSize: () => ITEM_HEIGHT,
overscan: 20,
});
// Get selected channel info — fall back to the prop for manual entries
const selectedChannel = useMemo(() => {
if (!selectedChannelId || !channelsData?.channels) return null;
return channelsData.channels.find(
if (!selectedChannelId) return null;
const fromList = channelsData?.channels?.find(
(channel) => channel.id === selectedChannelId,
);
}, [selectedChannelId, channelsData?.channels]);
return fromList ?? selectedChannelProp ?? null;
}, [selectedChannelId, channelsData?.channels, selectedChannelProp]);
// Handle channel selection
const handleChannelSelect = (channel: SlackChannel) => {
onChannelSelect(channel);
setOpen(false);
setSearchValue("");
};
const selectAndClose = useCallback(
(channel: SlackChannel) => {
onChannelSelect(channel);
setOpen(false);
setSearchValue("");
},
[onChannelSelect],
);
const handleSelectByName = useCallback(() => {
const name = searchValue.trim().replace(/^#/, "");
if (!name) return;
selectAndClose({
id: `#${name}`,
name,
isPrivate: false,
isMember: false,
});
}, [searchValue, selectAndClose]);
useEffect(() => {
if (scrollNode) {
scrollNode.scrollTop = 0;
}
}, [effectiveName, scrollNode]);
// Render channel item
const renderChannelItem = (channel: SlackChannel) => (
@@ -219,10 +249,23 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
);
}
const hasExactMatch = filteredChannels.some(
(channel) => channel.name.toLowerCase() === effectiveName.toLowerCase(),
);
const canUseTypedName = effectiveName.length > 0 && !hasExactMatch;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<Popover
open={open}
onOpenChange={(newOpen) => {
setOpen(newOpen);
if (!newOpen) {
setSearchValue("");
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -246,23 +289,51 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
<CommandEmpty>
{searchValue
? "No channels match your search."
: "No channels available."}
</CommandEmpty>
<CommandGroup>
{filteredChannels.map((channel) => (
<CommandList ref={setScrollNode}>
{canUseTypedName && (
<CommandGroup className="p-0">
<CommandItem
key={channel.id}
value={channel.id}
onSelect={() => handleChannelSelect(channel)}
value={`use-${effectiveName}`}
onSelect={handleSelectByName}
className="cursor-pointer"
>
{renderChannelItem(channel)}
<Hash className="text-muted-foreground h-4 w-4" />
<span className="flex-1 truncate">
Use &quot;{effectiveName}&quot;
</span>
</CommandItem>
))}
</CommandGroup>
)}
{!canUseTypedName && filteredChannels.length === 0 && (
<CommandEmpty>No channels available.</CommandEmpty>
)}
<CommandGroup
className="p-0"
style={{
height: virtualizer.getTotalSize(),
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const channel = filteredChannels[virtualRow.index];
return (
<CommandItem
key={channel.id}
value={channel.id}
onSelect={() => selectAndClose(channel)}
className="cursor-pointer"
style={{
position: "absolute",
top: virtualRow.start,
left: 0,
width: "100%",
height: ITEM_HEIGHT,
}}
>
{renderChannelItem(channel)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
@@ -290,6 +361,30 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
{memberOnly && " (member only)"}
</div>
)}
{/* Private channel scope warning */}
{channelsData && !channelsData.hasPrivateChannelAccess && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Private channels are not visible. To access private channels,{" "}
<button
type="button"
className="font-medium underline"
onClick={() =>
window.open(
`/api/public/slack/install?projectId=${projectId}`,
"slack-reauth",
"width=600,height=700",
)
}
>
re-authenticate your Slack integration
</button>{" "}
to grant the required permissions.
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -22,8 +22,12 @@ interface SlackTestMessageButtonProps {
size?: ButtonProps["size"];
/** Custom button text */
buttonText?: string;
/** Callback when test message is sent successfully */
onSuccess?: () => void;
/** Callback when test message is sent successfully, receives the resolved channel info */
onSuccess?: (channelInfo: {
id: string;
name?: string;
isPrivate?: boolean;
}) => void;
/** Callback when test message fails */
onError?: (error: Error) => void;
/** Whether to show the button text */
@@ -52,12 +56,12 @@ export const SlackTestMessageButton: React.FC<SlackTestMessageButtonProps> = ({
}) => {
// Test message mutation
const testMessageMutation = api.slack.sendTestMessage.useMutation({
onSuccess: () => {
onSuccess: (data) => {
showSuccessToast({
title: "Test Message Sent",
description: "Test message sent successfully to the selected channel.",
});
onSuccess?.();
onSuccess?.(data.channelInfo);
},
onError: (error) => {
showErrorToast("Failed to Send Test Message", error.message);
@@ -73,7 +77,7 @@ export const SlackTestMessageButton: React.FC<SlackTestMessageButtonProps> = ({
await testMessageMutation.mutateAsync({
projectId,
channelId: selectedChannel.id,
channelName: selectedChannel.name,
channelName: selectedChannel.name ?? undefined,
});
} catch {
// Error handling is done in the mutation
@@ -1,6 +1,7 @@
import { type NextApiRequest, type NextApiResponse } from "next";
import {
SlackService,
SLACK_BOT_SCOPES,
parseSlackInstallationMetadata,
} from "@langfuse/shared/src/server";
import { logger } from "@langfuse/shared/src/server";
@@ -30,7 +31,7 @@ export async function handleInstallPath(
// 2. Set session cookies for state validation
// 3. Render the installation page with "Add to Slack" button
const installOptions = {
scopes: ["channels:read", "chat:write", "chat:write.public"],
scopes: [...SLACK_BOT_SCOPES],
metadata: JSON.stringify({ projectId: projectId }),
redirectUri: `${env.NEXTAUTH_URL}/api/public/slack/oauth`,
};
+54 -8
View File
@@ -3,7 +3,7 @@ import {
protectedProjectProcedure,
} from "@/src/server/api/trpc";
import { z } from "zod";
import { SlackService } from "@langfuse/shared/src/server";
import { SlackService, SlackApiError } from "@langfuse/shared/src/server";
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
import { logger } from "@langfuse/shared/src/server";
import { TRPCError } from "@trpc/server";
@@ -112,7 +112,8 @@ export const slackRouter = createTRPCRouter({
const client = await slackService.getWebClientForProject(
input.projectId,
);
const channels = await slackService.getChannels(client);
const { channels, hasPrivateChannelAccess } =
await slackService.getChannels(client);
await auditLog({
session: ctx.session,
@@ -124,6 +125,7 @@ export const slackRouter = createTRPCRouter({
return {
channels,
hasPrivateChannelAccess,
teamId: integration.teamId,
teamName: integration.teamName,
};
@@ -201,8 +203,9 @@ export const slackRouter = createTRPCRouter({
.input(
z.object({
projectId: z.string(),
// Slack resolves both channel IDs (C1234) and names (#general)
channelId: z.string(),
channelName: z.string(),
channelName: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -253,7 +256,7 @@ export const slackRouter = createTRPCRouter({
},
{
type: "mrkdwn",
text: `*Channel:*\n#${input.channelName}`,
text: `*Channel:*\n#${input.channelName ?? input.channelId.replace(/^#/, "")}`,
},
{
type: "mrkdwn",
@@ -289,6 +292,30 @@ export const slackRouter = createTRPCRouter({
text: "Test message from Langfuse",
});
// For manually-typed channel names (id starts with #), resolve
// channel metadata via conversations.info so the UI can show
// accurate type/ID info. Skip for channels already selected from
// the list since we already have their metadata.
let channelInfo: {
id: string;
name?: string;
isPrivate?: boolean;
} = { id: result.channel };
if (input.channelId.startsWith("#")) {
const resolved = await SlackService.getInstance().getChannelInfo(
client,
result.channel,
);
if (resolved) {
channelInfo = {
id: resolved.id,
name: resolved.name,
isPrivate: resolved.isPrivate,
};
}
}
await auditLog({
session: ctx.session,
resourceType: "slackIntegration",
@@ -296,7 +323,7 @@ export const slackRouter = createTRPCRouter({
action: "create",
after: {
action: "test_message_sent",
channelId: input.channelId,
channelId: result.channel,
channelName: input.channelName,
messageTs: result.messageTs,
},
@@ -304,7 +331,7 @@ export const slackRouter = createTRPCRouter({
logger.info("Test message sent successfully", {
projectId: input.projectId,
channelId: input.channelId,
channelId: result.channel,
channelName: input.channelName,
messageTs: result.messageTs,
});
@@ -313,6 +340,7 @@ export const slackRouter = createTRPCRouter({
success: true,
messageTs: result.messageTs,
channel: result.channel,
channelInfo,
};
} catch (error) {
logger.error("Failed to send test message", {
@@ -321,10 +349,28 @@ export const slackRouter = createTRPCRouter({
channelId: input.channelId,
});
const slackError =
error instanceof SlackApiError ? error.slackErrorCode : undefined;
const userMessage = (() => {
switch (slackError) {
case "channel_not_found":
return 'Channel not found. The channel may not exist or is a private channel the bot has not been invited to. For private channels, invite the app with "/invite @Langfuse" in that channel.';
case "not_in_channel":
return "The bot is not a member of this channel. Please invite the bot to the channel first.";
case "is_archived":
return "This channel has been archived and cannot receive messages.";
case "invalid_auth":
case "token_revoked":
return "Slack authentication failed. Please reconnect your Slack workspace.";
default:
return "Failed to send test message. Please check your Slack connection and channel permissions.";
}
})();
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Failed to send test message. Please check your Slack connection and channel permissions.",
message: userMessage,
});
}
}),
@@ -119,19 +119,20 @@ export function SelectWidgetDialog({
selectedWidgetId === widget.id ? "bg-muted" : ""
}`}
>
<TableCell className="font-medium">
<TableCell density="comfortable" className="font-medium">
{widget.name}
</TableCell>
<TableCell
density="comfortable"
className="truncate"
title={widget.description}
>
{widget.description}
</TableCell>
<TableCell>
<TableCell density="comfortable">
{startCase(widget.view.toLowerCase())}
</TableCell>
<TableCell>
<TableCell density="comfortable">
{getChartTypeDisplayName(
widget.chartType as DashboardWidgetChartType,
)}
@@ -27,10 +27,9 @@ export default withMiddlewares({
const datasetItem = await getDatasetItemById({
projectId: auth.scope.projectId,
datasetItemId: datasetItemId,
status: "ACTIVE",
});
if (!datasetItem) {
throw new LangfuseNotFoundError("Dataset item not found or archived");
throw new LangfuseNotFoundError("Dataset item not found");
}
const dataset = await prisma.dataset.findUnique({
@@ -8,7 +8,11 @@ import {
PostDatasetItemsV1Response,
transformDbDatasetItemDomainToAPIDatasetItem,
} from "@/src/features/public-api/types/datasets";
import { LangfuseNotFoundError, Prisma } from "@langfuse/shared";
import {
LangfuseConflictError,
LangfuseNotFoundError,
Prisma,
} from "@langfuse/shared";
import {
createDatasetItemFilterState,
getDatasetItems,
@@ -82,12 +86,23 @@ export default withMiddlewares({
// When this constraint is violated, the database will upsert based on (id, projectId, datasetId).
// If this record does not exist, the database will throw an error.
logger.warn(
`Failed to upsert dataset item. Dataset item ${id} in project ${auth.scope.projectId} already exists for a different dataset than ${datasetName}`,
`Failed to upsert dataset item. Dataset item ${id} already exists for a different dataset than ${datasetName}`,
);
throw new LangfuseNotFoundError(
`The dataset item with id ${id} already exists in a dataset other than ${datasetName}`,
);
}
if (e.code === "P2002") {
// Unique constraint violation on (id, projectId, validFrom).
// This can happen when concurrent requests try to update the same dataset item
// and create versions with the same timestamp.
logger.warn(
`Failed to upsert dataset item due to version conflict. Dataset item ${id} was modified concurrently.`,
);
throw new LangfuseConflictError(
`Dataset item ${id ?? "new"} was modified concurrently. Please retry the request.`,
);
}
}
throw e;
}
@@ -11,6 +11,8 @@ import {
import { encrypt } from "@langfuse/shared/encryption";
import { getDisplaySecretKey } from "@/src/features/llm-api-key/server/router";
import { auditLog } from "@/src/features/audit-logs/auditLog";
import { InvalidRequestError } from "@langfuse/shared";
import { validateLlmConnectionBaseURL } from "@langfuse/shared/src/server";
export default withMiddlewares({
GET: createAuthedProjectAPIRoute({
@@ -85,11 +87,21 @@ export default withMiddlewares({
provider: body.provider,
},
},
select: { id: true },
select: { id: true, baseURL: true },
});
const isUpdate = Boolean(existingConnection);
if (body.baseURL && body.baseURL !== existingConnection?.baseURL) {
try {
await validateLlmConnectionBaseURL(body.baseURL);
} catch (error) {
throw new InvalidRequestError(
`Invalid baseURL: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
const llmConnectionBody = {
adapter: body.adapter,
secretKey: encrypt(body.secretKey),
+8
View File
@@ -14,6 +14,7 @@ import {
logger,
processEventBatch,
} from "@langfuse/shared/src/server";
import { ForbiddenError } from "@langfuse/shared";
import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service";
export default withMiddlewares({
@@ -21,7 +22,14 @@ export default withMiddlewares({
name: "Create Score",
bodySchema: PostScoresBodyV1,
responseSchema: PostScoresResponseV1,
allowedAccessLevels: ["project", "scores"],
fn: async ({ body, auth, res }) => {
if (auth.scope.isIngestionSuspended) {
throw new ForbiddenError(
"Ingestion suspended: Usage threshold exceeded. Please upgrade your plan.",
);
}
const event = {
id: v4(),
type: eventTypes.SCORE_CREATE,
+58 -22
View File
@@ -6,7 +6,10 @@ import {
GetTraceV1Response,
DeleteTraceV1Query,
DeleteTraceV1Response,
TRACE_FIELD_GROUPS,
type TraceFieldGroup,
} from "@/src/features/public-api/types/traces";
import { env } from "@/src/env.mjs";
import {
filterAndValidateDbTraceScoreList,
LangfuseNotFoundError,
@@ -29,11 +32,32 @@ export default withMiddlewares({
responseSchema: GetTraceV1Response,
fn: async ({ query, auth }) => {
const { traceId } = query;
let effectiveFields: readonly TraceFieldGroup[] =
query.fields ?? TRACE_FIELD_GROUPS;
if (!query.fields && env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS) {
const parsed = env.LANGFUSE_API_TRACEBYID_DEFAULT_FIELDS.split(",")
.map((f) => f.trim())
.filter((f): f is TraceFieldGroup =>
TRACE_FIELD_GROUPS.includes(f as TraceFieldGroup),
);
if (parsed.length > 0) {
effectiveFields = parsed;
}
}
const requestedFields = effectiveFields;
const includeIO = requestedFields.includes("io");
const includeObservations = requestedFields.includes("observations");
const includeScores = requestedFields.includes("scores");
const includeMetrics = requestedFields.includes("metrics");
const trace = await getTraceById({
traceId,
projectId: auth.scope.projectId,
clickhouseFeatureTag: "tracing-public-api",
preferredClickhouseService: "ReadOnly",
excludeInputOutput: !includeIO,
excludeMetadata: !includeIO,
});
if (!trace) {
@@ -43,19 +67,23 @@ export default withMiddlewares({
}
const [observations, scores] = await Promise.all([
getObservationsForTrace({
traceId,
projectId: auth.scope.projectId,
timestamp: trace?.timestamp,
includeIO: true,
preferredClickhouseService: "ReadOnly",
}),
getScoresForTraces({
projectId: auth.scope.projectId,
traceIds: [traceId],
timestamp: trace?.timestamp,
preferredClickhouseService: "ReadOnly",
}),
includeObservations || includeMetrics
? getObservationsForTrace({
traceId,
projectId: auth.scope.projectId,
timestamp: trace?.timestamp,
includeIO: includeObservations,
preferredClickhouseService: "ReadOnly",
})
: Promise.resolve([]),
includeScores
? getScoresForTraces({
projectId: auth.scope.projectId,
traceIds: [traceId],
timestamp: trace?.timestamp,
preferredClickhouseService: "ReadOnly",
})
: Promise.resolve([]),
]);
const uniqueModels: string[] = Array.from(
@@ -129,16 +157,24 @@ export default withMiddlewares({
return {
...trace,
externalId: null,
scores: validatedScores,
latency: latencyMs !== undefined ? latencyMs / 1000 : 0,
observations: outObservations,
metadata: includeIO ? trace.metadata : {},
scores: includeScores ? validatedScores : [],
latency: includeMetrics
? latencyMs !== undefined
? latencyMs / 1000
: 0
: -1,
observations: includeObservations ? outObservations : [],
htmlPath: `/project/${auth.scope.projectId}/traces/${traceId}`,
totalCost: outObservations
.reduce(
(acc, obs) => acc.add(obs.calculatedTotalCost ?? new Decimal(0)),
new Decimal(0),
)
.toNumber(),
totalCost: includeMetrics
? outObservations
.reduce(
(acc, obs) =>
acc.add(obs.calculatedTotalCost ?? new Decimal(0)),
new Decimal(0),
)
.toNumber()
: -1,
};
},
}),
@@ -392,7 +392,7 @@ const BlobStorageIntegrationSettingsForm = ({
</FormControl>
<FormDescription>
{integrationType === "AZURE_BLOB_STORAGE"
? "The Azure storage container name"
? "Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only)"
: "The S3 bucket name"}
</FormDescription>
<FormMessage />
@@ -86,6 +86,9 @@ export default function SlackIntegrationSettings() {
scope: "automations:CUD",
});
// Channel was typed by name rather than selected from the list
const isManualEntry = selectedChannel?.id.startsWith("#") ?? false;
return (
<ContainerPage
headerProps={{
@@ -121,6 +124,7 @@ export default function SlackIntegrationSettings() {
<ChannelSelector
projectId={projectId}
selectedChannelId={selectedChannel?.id}
selectedChannel={selectedChannel}
onChannelSelect={setSelectedChannel}
placeholder="Choose a channel to test"
showRefreshButton={true}
@@ -143,15 +147,27 @@ export default function SlackIntegrationSettings() {
</div>
<div>
<p className="text-sm font-medium">Channel Type</p>
<Badge variant="outline" className="text-xs">
{selectedChannel.isPrivate ? "Private" : "Public"}
</Badge>
{isManualEntry ? (
<span className="text-muted-foreground text-xs">
Available after sending a test message
</span>
) : (
<Badge variant="outline" className="text-xs">
{selectedChannel.isPrivate ? "Private" : "Public"}
</Badge>
)}
</div>
<div>
<p className="text-sm font-medium">Channel ID</p>
<p className="text-muted-foreground font-mono text-sm">
{selectedChannel.id}
</p>
{isManualEntry ? (
<span className="text-muted-foreground text-xs">
Available after sending a test message
</span>
) : (
<p className="text-muted-foreground font-mono text-sm">
{selectedChannel.id}
</p>
)}
</div>
</div>
</div>
@@ -162,6 +178,19 @@ export default function SlackIntegrationSettings() {
selectedChannel={selectedChannel}
hasAccess={hasAccess}
disabled={false}
onSuccess={(channelInfo) => {
setSelectedChannel((prev) =>
prev
? {
...prev,
id: channelInfo.id,
name: channelInfo.name ?? prev.name,
isPrivate:
channelInfo.isPrivate ?? prev.isPrivate,
}
: prev,
);
}}
/>
</div>
</div>
@@ -170,7 +199,11 @@ export default function SlackIntegrationSettings() {
{!selectedChannel && (
<div className="text-muted-foreground text-sm">
Select a channel above to view its details and test message
delivery.
delivery. For private channels, invite the app first with{" "}
<code className="bg-muted rounded px-1 py-0.5">
/invite @Langfuse
</code>{" "}
in that channel.
</div>
)}
</CardContent>

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