Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a13377e35 | ||
|
|
30af822ac9 | ||
|
|
c2c0b661e7 | ||
|
|
2e94ebfe4b | ||
|
|
b8544b3423 | ||
|
|
24cc309fb8 | ||
|
|
1ca70d7033 | ||
|
|
ba980c302e | ||
|
|
ea197e4287 | ||
|
|
0b20e4d366 | ||
|
|
31a1a34616 | ||
|
|
3c3d4bf129 | ||
|
|
07cae52cc7 | ||
|
|
a81edec0be | ||
|
|
497179934d | ||
|
|
ad9dfc41a2 | ||
|
|
9cc69f4c67 | ||
|
|
557f284cd1 | ||
|
|
6702c7b50f | ||
|
|
25d99aa371 | ||
|
|
e4d5f914cc | ||
|
|
dcb5dbf528 | ||
|
|
d003a9c3f4 | ||
|
|
d2d56f0337 | ||
|
|
6c0cf07a5a | ||
|
|
dd632fea9e | ||
|
|
7527bb0d84 | ||
|
|
8cc4a5537f | ||
|
|
0a6d3f108a | ||
|
|
1bf83313e3 | ||
|
|
9c3a715d77 | ||
|
|
0cf2a33473 | ||
|
|
51554eb066 | ||
|
|
cbc21bb9cc | ||
|
|
8e30694214 | ||
|
|
0d20d9de2b | ||
|
|
eeeba25439 | ||
|
|
a59630d656 | ||
|
|
10e7dea9c9 | ||
|
|
9bf326db4f | ||
|
|
13190c3ec4 | ||
|
|
7a4ce9ee5d | ||
|
|
bc02989ccf | ||
|
|
f0dac0299c | ||
|
|
19997064c1 | ||
|
|
c393c64a40 | ||
|
|
d04e027107 | ||
|
|
53869c7200 | ||
|
|
6d964894fb | ||
|
|
81653b2bd2 | ||
|
|
02d6486f10 | ||
|
|
07ee4ed961 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 8
|
||||
default-days: 5
|
||||
versioning-strategy: "increase"
|
||||
commit-message:
|
||||
prefix: chore
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
export const VERSION = "v3.165.0";
|
||||
export const VERSION = "v3.167.2";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
Generated
+1550
-1018
File diff suppressed because it is too large
Load Diff
+36
-63
@@ -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
|
||||
|
||||
Executable
+461
@@ -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"
|
||||
}
|
||||
Executable
+20
@@ -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
|
||||
Executable
+42
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
]}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
+3
-1
@@ -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>
|
||||
|
||||
@@ -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 @@
|
||||
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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 "{effectiveName}"
|
||||
</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`,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user