Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02449cbe0a | ||
|
|
616c68a87b | ||
|
|
319b22ae78 | ||
|
|
b919562eed | ||
|
|
91c01e7363 | ||
|
|
48ec1bccae | ||
|
|
66aa2fd48e | ||
|
|
f822bf1049 | ||
|
|
96ec24aad8 | ||
|
|
068fbd0cc6 | ||
|
|
7c8b76d573 | ||
|
|
e4e93d64bb | ||
|
|
f5cf8693e9 | ||
|
|
5beef28214 | ||
|
|
d93712c6ab | ||
|
|
b1b0a966be | ||
|
|
b5edadb203 | ||
|
|
3090882590 | ||
|
|
787f5d8d10 | ||
|
|
5fa142eda4 | ||
|
|
d96865b019 | ||
|
|
8d808923a1 | ||
|
|
0f056c1c2d | ||
|
|
094a523795 | ||
|
|
c60e58a568 | ||
|
|
6147e9c518 | ||
|
|
f0408b208e | ||
|
|
55e0bdcac0 | ||
|
|
05e15c9f53 | ||
|
|
ce6249b266 | ||
|
|
83e91e5bf5 | ||
|
|
edf073cc18 | ||
|
|
65343a8e9b | ||
|
|
0978055bdc | ||
|
|
76aaf2a3d0 | ||
|
|
8ed61bf705 | ||
|
|
45436530b4 | ||
|
|
7a3f01fc0a | ||
|
|
468fc4d079 | ||
|
|
945d13dac0 | ||
|
|
3646eaa5a8 | ||
|
|
feb5af11b8 | ||
|
|
3711af5663 | ||
|
|
9ec798f393 | ||
|
|
f71fd43d91 | ||
|
|
0303596420 | ||
|
|
cfaccc46d3 | ||
|
|
907f764adc | ||
|
|
652e9e3fe5 | ||
|
|
48673e43d7 | ||
|
|
a136ac8184 | ||
|
|
9ebf62fc9c | ||
|
|
cf52dce773 | ||
|
|
0a6ecf1cba | ||
|
|
c359900b59 | ||
|
|
720ed7a36f | ||
|
|
1dbefb0994 | ||
|
|
abfdd2366a | ||
|
|
1f9bb37f3b | ||
|
|
00afab9725 | ||
|
|
d04e808515 | ||
|
|
ee04912f0e | ||
|
|
7f04a54c0c | ||
|
|
cce11ef6b5 | ||
|
|
1b03f537e2 | ||
|
|
dba114e4a3 | ||
|
|
fefe3bc3bd | ||
|
|
800cc299cb | ||
|
|
1bdaed4314 | ||
|
|
71dde233c5 | ||
|
|
3b80afdf2d | ||
|
|
b69f0bda9d | ||
|
|
9b9a58ae45 | ||
|
|
775e6a8bfc | ||
|
|
5253014fa6 | ||
|
|
1571de133b | ||
|
|
9202f4ec79 | ||
|
|
949e3d1181 | ||
|
|
2d280329c5 | ||
|
|
6e1886055a | ||
|
|
39e4a7533a | ||
|
|
e8787e3f38 | ||
|
|
59055cd311 | ||
|
|
7712b63b95 | ||
|
|
f25d466388 | ||
|
|
b9e18f59d8 | ||
|
|
09f3343db2 | ||
|
|
5ab8e4219f | ||
|
|
40359f0e3f | ||
|
|
817b6d37bc | ||
|
|
8bb73822d3 | ||
|
|
fa53ba3c1a | ||
|
|
086a764083 | ||
|
|
79a9f3c3e3 | ||
|
|
abd46f2d4d | ||
|
|
dfd9565a3f | ||
|
|
ebb8a5a13f | ||
|
|
44104596f6 | ||
|
|
17f6260671 | ||
|
|
0a2bfe5ab5 | ||
|
|
8bd1a4da3e | ||
|
|
31b230d3e0 | ||
|
|
dbe71249e7 | ||
|
|
a9d0486edd | ||
|
|
2bf506a436 | ||
|
|
edca91c12a | ||
|
|
a11e7ddfee | ||
|
|
cb9eb23f36 | ||
|
|
5e942f0208 | ||
|
|
8e0e139848 | ||
|
|
acc1227962 | ||
|
|
d9682511c1 | ||
|
|
00e0b51bc4 | ||
|
|
e63cf84e4f | ||
|
|
63d1051ac2 | ||
|
|
0e056eb072 | ||
|
|
87f7095966 | ||
|
|
5eefe997ae | ||
|
|
e251e60b53 | ||
|
|
adc05eeef4 | ||
|
|
702143fbe2 | ||
|
|
06643f30e5 | ||
|
|
2e496d5429 | ||
|
|
5b62df9122 | ||
|
|
fd189dc66b | ||
|
|
10a3644dcf | ||
|
|
8ca000b225 | ||
|
|
47d41b6a42 | ||
|
|
1d66408c18 | ||
|
|
44d1727dd0 | ||
|
|
bfb2878719 | ||
|
|
67e6b4ce87 | ||
|
|
dcc5bf9c6e | ||
|
|
db357101b7 | ||
|
|
afbd45b9c3 | ||
|
|
b6b37212ce | ||
|
|
76a090241e | ||
|
|
c53abf6aeb | ||
|
|
5cbfd922f9 | ||
|
|
0be269d519 | ||
|
|
d0eeb44b90 | ||
|
|
30ced3ab42 | ||
|
|
2ed6b5a40f | ||
|
|
6eaef75f20 | ||
|
|
31c36565c3 | ||
|
|
d1a1ca7744 | ||
|
|
2dac75d203 | ||
|
|
fab90b36a6 | ||
|
|
597bd6c96b | ||
|
|
ba3bcc9295 | ||
|
|
5f65929a5e | ||
|
|
f1344a4c1a | ||
|
|
4e3b9a82f7 | ||
|
|
7b803619fd | ||
|
|
1ef51b1644 | ||
|
|
fee9e34b7e | ||
|
|
1354db489f | ||
|
|
740a25150c | ||
|
|
47c2d65d12 | ||
|
|
d94e7c1c06 | ||
|
|
38ed98a76d | ||
|
|
92567b765b | ||
|
|
b37396893a | ||
|
|
0fd1eebbff | ||
|
|
f478bb5763 |
+4
-4
@@ -157,10 +157,6 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=
|
||||
# LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=
|
||||
|
||||
# Exports are streamed to S3 in pages to avoid memory issues
|
||||
# The page size can be adjusted if needed to optimize performance
|
||||
# DB_EXPORT_PAGE_SIZE=1000
|
||||
|
||||
# Automated provisioning of default resources
|
||||
# LANGFUSE_INIT_ORG_ID=org-id
|
||||
# LANGFUSE_INIT_ORG_NAME=org-name
|
||||
@@ -215,6 +211,8 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_OPENAI=https://api.openai.com/v1
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_ANTHROPIC=https://api.anthropic.com
|
||||
# LANGFUSE_UI_DEFAULT_BASE_URL_AZURE_OPENAI=https://{instanceName}.openai.azure.com/openai/deployments
|
||||
# LANGFUSE_UI_VISIBLE_PRODUCT_MODULES=
|
||||
# LANGFUSE_UI_HIDDEN_PRODUCT_MODULES=
|
||||
|
||||
### END Enterprise Edition Configuration
|
||||
|
||||
@@ -253,6 +251,8 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# Plain Chat
|
||||
# NEXT_PUBLIC_PLAIN_APP_ID=
|
||||
# PLAIN_AUTHENTICATION_SECRET=
|
||||
# PLAIN_API_KEY=
|
||||
# PLAIN_CARDS_API_TOKEN=
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
|
||||
@@ -15,6 +15,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ inputs.environment }}
|
||||
steps:
|
||||
- name: Set Swap Space
|
||||
uses: pierotofy/set-swap-space@master
|
||||
with:
|
||||
swap-size-gb: 10
|
||||
- name: Get app name
|
||||
uses: winterjung/split@v2
|
||||
id: split
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Codex Guidelines for Langfuse
|
||||
|
||||
Langfuse is an **open source LLM engineering** platform for developing, monitoring, evaluating and debugging AI applications. See the README for more details.
|
||||
|
||||
## Linting
|
||||
- Run `pnpm run lint` to lint all packages.
|
||||
- Fix issues automatically with `pnpm run lint:fix`.
|
||||
|
||||
## Tests
|
||||
- Codex cannot run the test suite because it depends on Docker-based infrastructure that is unavailable in this environment.
|
||||
|
||||
## Cursor Rules
|
||||
- Additional folder-specific rules live in `.cursor/rules/`.
|
||||
|
||||
## Commits
|
||||
- Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) when crafting commit messages.
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "^14.2.26",
|
||||
"next-auth": "^4.24.11",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
|
||||
@@ -457,13 +457,13 @@ types:
|
||||
docs: Unit used by this model.
|
||||
type: optional<ModelUsageUnit>
|
||||
inputPrice:
|
||||
docs: Price (USD) per input unit
|
||||
docs: Deprecated. See 'prices' instead. Price (USD) per input unit
|
||||
type: optional<double>
|
||||
outputPrice:
|
||||
docs: Price (USD) per output unit
|
||||
docs: Deprecated. See 'prices' instead. Price (USD) per output unit
|
||||
type: optional<double>
|
||||
totalPrice:
|
||||
docs: Price (USD) per total unit. Cannot be set if input or output price is set.
|
||||
docs: Deprecated. See 'prices' instead. Price (USD) per total unit. Cannot be set if input or output price is set.
|
||||
type: optional<double>
|
||||
tokenizerId:
|
||||
docs: Optional. Tokenizer to be applied to observations which match to this model. See docs for more details.
|
||||
@@ -473,6 +473,13 @@ types:
|
||||
type: optional<unknown>
|
||||
isLangfuseManaged:
|
||||
type: boolean
|
||||
prices:
|
||||
docs: Price (USD) by usage type
|
||||
type: map<string, ModelPrice>
|
||||
|
||||
ModelPrice:
|
||||
properties:
|
||||
price: double
|
||||
|
||||
# Utilities
|
||||
ModelUsageUnit:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
pagination: ./utils/pagination.yml
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
@@ -11,6 +12,23 @@ service:
|
||||
path: /dataset-run-items
|
||||
request: CreateDatasetRunItemRequest
|
||||
response: commons.DatasetRunItem
|
||||
list:
|
||||
method: GET
|
||||
docs: List dataset run items
|
||||
path: /dataset-run-items
|
||||
request:
|
||||
name: ListDatasetRunItemsRequest
|
||||
query-parameters:
|
||||
datasetId: string
|
||||
runName: string
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
response: PaginatedDatasetRunItems
|
||||
|
||||
types:
|
||||
CreateDatasetRunItemRequest:
|
||||
properties:
|
||||
@@ -26,3 +44,7 @@ types:
|
||||
traceId:
|
||||
type: optional<string>
|
||||
docs: traceId should always be provided. For compatibility with older SDK versions it can also be inferred from the provided observationId.
|
||||
PaginatedDatasetRunItems:
|
||||
properties:
|
||||
data: list<commons.DatasetRunItem>
|
||||
meta: pagination.MetaResponse
|
||||
|
||||
@@ -6,71 +6,59 @@ service:
|
||||
auth: true
|
||||
base-path: /api/public
|
||||
endpoints:
|
||||
daily:
|
||||
docs: Get daily metrics of the Langfuse project
|
||||
metrics:
|
||||
docs: Get metrics from the Langfuse project using a query object
|
||||
method: GET
|
||||
path: /metrics/daily
|
||||
path: /metrics
|
||||
request:
|
||||
name: GetDailyMetricsRequest
|
||||
name: GetMetricsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: page number, starts at 1
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: limit of items per page
|
||||
traceName:
|
||||
type: optional<string>
|
||||
docs: Optional filter by the name of the trace
|
||||
userId:
|
||||
type: optional<string>
|
||||
docs: Optional filter by the userId associated with the trace
|
||||
tags:
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Optional filter for metrics where traces include all of these tags
|
||||
environment:
|
||||
type: optional<string>
|
||||
allow-multiple: true
|
||||
docs: Optional filter for metrics where events include any of these environments
|
||||
fromTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)
|
||||
toTimestamp:
|
||||
type: optional<datetime>
|
||||
docs: Optional filter to only include traces and observations before a certain datetime (ISO 8601)
|
||||
response: DailyMetrics
|
||||
query:
|
||||
type: string
|
||||
docs: |
|
||||
JSON string containing the query parameters with the following structure:
|
||||
```json
|
||||
{
|
||||
"view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-categorical"
|
||||
"dimensions": [ // Optional. Default: []
|
||||
{
|
||||
"field": string // Field to group by, e.g. "name", "userId", "sessionId"
|
||||
}
|
||||
],
|
||||
"metrics": [ // Required. At least one metric must be provided
|
||||
{
|
||||
"measure": string, // What to measure, e.g. "count", "latency", "value"
|
||||
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95"
|
||||
}
|
||||
],
|
||||
"filters": [ // Optional. Default: []
|
||||
{
|
||||
"column": string, // Column to filter on
|
||||
"operator": string, // Operator, e.g. "=", ">", "<", "contains"
|
||||
"value": any, // Value to compare against
|
||||
"type": string, // Data type, e.g. "string", "number", "stringObject"
|
||||
"key": string // Required only when filtering on metadata
|
||||
}
|
||||
],
|
||||
"timeDimension": { // Optional. Default: null. If provided, results will be grouped by time
|
||||
"granularity": string // One of "minute", "hour", "day", "week", "month", "auto"
|
||||
},
|
||||
"fromTimestamp": string, // Required. ISO datetime string for start of time range
|
||||
"toTimestamp": string, // Required. ISO datetime string for end of time range
|
||||
"orderBy": [ // Optional. Default: null
|
||||
{
|
||||
"field": string, // Field to order by
|
||||
"direction": string // "asc" or "desc"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
response: MetricsResponse
|
||||
types:
|
||||
DailyMetrics:
|
||||
MetricsResponse:
|
||||
properties:
|
||||
data:
|
||||
type: list<DailyMetricsDetails>
|
||||
docs: A list of daily metrics, only days with ingested data are included.
|
||||
meta: pagination.MetaResponse
|
||||
DailyMetricsDetails:
|
||||
properties:
|
||||
date: date
|
||||
countTraces: integer
|
||||
countObservations: integer
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Total model cost in USD
|
||||
usage: list<UsageByModel>
|
||||
UsageByModel:
|
||||
docs: Daily usage of a given model. Usage corresponds to the unit set for the specific model (e.g. tokens).
|
||||
properties:
|
||||
model: optional<string>
|
||||
inputUsage:
|
||||
type: integer
|
||||
docs: Total number of generation input units (e.g. tokens)
|
||||
outputUsage:
|
||||
type: integer
|
||||
docs: Total number of generation output units (e.g. tokens)
|
||||
totalUsage:
|
||||
type: integer
|
||||
docs: Total number of generation total units (e.g. tokens)
|
||||
countTraces: integer
|
||||
countObservations: integer
|
||||
totalCost:
|
||||
type: double
|
||||
docs: Total model cost in USD
|
||||
type: list<map<string, unknown>>
|
||||
docs: |
|
||||
The metrics data. Each item in the list contains the metric values and dimensions requested in the query.
|
||||
Format varies based on the query parameters.
|
||||
|
||||
@@ -39,7 +39,7 @@ service:
|
||||
docs: Optional filter for observations where the environment is one of the provided values.
|
||||
fromStartTime:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only observations with a start_time or or after this datetime (ISO 8601).
|
||||
docs: Retrieve only observations with a start_time on or after this datetime (ISO 8601).
|
||||
toStartTime:
|
||||
type: optional<datetime>
|
||||
docs: Retrieve only observations with a start_time before this datetime (ISO 8601).
|
||||
|
||||
+19
-1
@@ -37,6 +37,12 @@ service:
|
||||
request: MembershipRequest
|
||||
response: MembershipResponse
|
||||
|
||||
getOrganizationProjects:
|
||||
docs: Get all projects for the organization associated with the API key (requires organization-scoped API key)
|
||||
method: GET
|
||||
path: /organizations/projects
|
||||
response: OrganizationProjectsResponse
|
||||
|
||||
types:
|
||||
MembershipRole:
|
||||
enum:
|
||||
@@ -59,4 +65,16 @@ types:
|
||||
|
||||
MembershipsResponse:
|
||||
properties:
|
||||
memberships: list<MembershipResponse>
|
||||
memberships: list<MembershipResponse>
|
||||
|
||||
OrganizationProject:
|
||||
properties:
|
||||
id: string
|
||||
name: string
|
||||
metadata: optional<map<string, unknown>>
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
OrganizationProjectsResponse:
|
||||
properties:
|
||||
projects: list<OrganizationProject>
|
||||
@@ -11,7 +11,7 @@ service:
|
||||
method: GET
|
||||
path: /projects
|
||||
response: Projects
|
||||
|
||||
|
||||
create:
|
||||
docs: Create a new project (requires organization-scoped API key)
|
||||
method: POST
|
||||
@@ -26,9 +26,9 @@ service:
|
||||
docs: Optional metadata for the project
|
||||
retention:
|
||||
type: integer
|
||||
docs: Number of days to retain data. Must be 0 or at least 7 days. Requires data-retention entitlement for non-zero values. Optional.
|
||||
docs: Number of days to retain data. Must be 0 or at least 3 days. Requires data-retention entitlement for non-zero values. Optional.
|
||||
response: Project
|
||||
|
||||
|
||||
update:
|
||||
docs: Update a project by ID (requires organization-scoped API key).
|
||||
method: PUT
|
||||
@@ -45,9 +45,9 @@ service:
|
||||
docs: Optional metadata for the project
|
||||
retention:
|
||||
type: integer
|
||||
docs: Number of days to retain data. Must be 0 or at least 7 days. Requires data-retention entitlement for non-zero values. Optional.
|
||||
docs: Number of days to retain data. Must be 0 or at least 3 days. Requires data-retention entitlement for non-zero values. Optional.
|
||||
response: Project
|
||||
|
||||
|
||||
delete:
|
||||
docs: Delete a project by ID (requires organization-scoped API key). Project deletion is processed asynchronously.
|
||||
method: DELETE
|
||||
@@ -57,7 +57,7 @@ service:
|
||||
response:
|
||||
status-code: 202
|
||||
type: ProjectDeletionResponse
|
||||
|
||||
|
||||
# API Key endpoints
|
||||
getApiKeys:
|
||||
docs: Get all API keys for a project (requires organization-scoped API key)
|
||||
@@ -104,14 +104,14 @@ types:
|
||||
type: map<string, unknown>
|
||||
docs: Metadata for the project
|
||||
retentionDays:
|
||||
type: integer
|
||||
type: optional<integer>
|
||||
docs: Number of days to retain data. Null or 0 means no retention. Omitted if no retention is configured.
|
||||
|
||||
ProjectDeletionResponse:
|
||||
properties:
|
||||
success: boolean
|
||||
message: string
|
||||
|
||||
|
||||
ApiKeyList:
|
||||
docs: List of API keys for a project
|
||||
properties:
|
||||
@@ -137,7 +137,7 @@ types:
|
||||
secretKey: string
|
||||
displaySecretKey: string
|
||||
note: optional<string>
|
||||
|
||||
|
||||
ApiKeyDeletionResponse:
|
||||
docs: Response for API key deletion
|
||||
properties:
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.56.0",
|
||||
"version": "3.66.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -86,7 +86,9 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"nanoid": "^3.3.8",
|
||||
"katex": "^0.16.21"
|
||||
"katex": "^0.16.21",
|
||||
"tar-fs": "^2.1.2",
|
||||
"rollup@^4.0.0": "^4.22.4"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP VIEW IF EXISTS analytics_traces ON CLUSTER default;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE VIEW analytics_traces ON CLUSTER default AS
|
||||
SELECT
|
||||
project_id,
|
||||
toStartOfHour(timestamp) AS hour,
|
||||
uniq(id) AS countTraces,
|
||||
max(user_id IS NOT NULL) AS hasUsers,
|
||||
max(session_id IS NOT NULL) AS hasSessions,
|
||||
max(if(environment != 'default', 1, 0)) AS hasEnvironments,
|
||||
max(length(tags) > 0) AS hasTags
|
||||
FROM
|
||||
traces
|
||||
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
|
||||
GROUP BY
|
||||
project_id,
|
||||
hour;
|
||||
@@ -0,0 +1 @@
|
||||
DROP VIEW IF EXISTS analytics_observations ON CLUSTER default;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE VIEW analytics_observations ON CLUSTER default AS
|
||||
SELECT
|
||||
project_id,
|
||||
type,
|
||||
toStartOfHour(start_time) AS hour,
|
||||
uniq(id) AS countObservations,
|
||||
max(level != 'DEFAULT') AS hasLevel,
|
||||
max(provided_model_name IS NOT NULL) AS hasProvidedModelName,
|
||||
max(length(provided_usage_details) > 0) AS hasProvidedUsageDetails,
|
||||
max(length(provided_cost_details) > 0) AS hasProvidedCostDetails,
|
||||
max(prompt_name IS NOT NULL) AS hasPromptName
|
||||
FROM
|
||||
observations
|
||||
WHERE toStartOfHour(start_time) <= toStartOfHour(subtractHours(now(), 1))
|
||||
GROUP BY
|
||||
project_id,
|
||||
type,
|
||||
hour;
|
||||
@@ -0,0 +1 @@
|
||||
DROP VIEW IF EXISTS analytics_scores ON CLUSTER default;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE VIEW analytics_scores ON CLUSTER default AS
|
||||
SELECT
|
||||
project_id,
|
||||
toStartOfHour(timestamp) AS hour,
|
||||
uniq(id) AS countScores,
|
||||
max(source = 'ANNOTATION') AS hasAnnotation,
|
||||
max(source = 'API') AS hasApi,
|
||||
max(source = 'EVAL') AS hasEval,
|
||||
max(observation_id IS NOT NULL) AS hasObservationScore,
|
||||
max(session_id IS NOT NULL) AS hasSessionScore,
|
||||
max(dataset_run_id IS NOT NULL) AS hasDatasetRunScore,
|
||||
max(data_type = 'BOOLEAN') AS hasBoolScore,
|
||||
max(data_type = 'NUMERIC') AS hasNumericScore,
|
||||
max(data_type = 'CATEGORICAL') AS hasCategoricalScore,
|
||||
max(comment IS NOT NULL) AS hasComment
|
||||
FROM
|
||||
scores
|
||||
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
|
||||
GROUP BY
|
||||
project_id,
|
||||
hour;
|
||||
@@ -0,0 +1 @@
|
||||
DROP VIEW IF EXISTS analytics_traces;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE VIEW analytics_traces AS
|
||||
SELECT
|
||||
project_id,
|
||||
toStartOfHour(timestamp) AS hour,
|
||||
uniq(id) AS countTraces,
|
||||
max(user_id IS NOT NULL) AS hasUsers,
|
||||
max(session_id IS NOT NULL) AS hasSessions,
|
||||
max(if(environment != 'default', 1, 0)) AS hasEnvironments,
|
||||
max(length(tags) > 0) AS hasTags
|
||||
FROM
|
||||
traces
|
||||
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
|
||||
GROUP BY
|
||||
project_id,
|
||||
hour;
|
||||
@@ -0,0 +1 @@
|
||||
DROP VIEW IF EXISTS analytics_observations;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE VIEW analytics_observations AS
|
||||
SELECT
|
||||
project_id,
|
||||
type,
|
||||
toStartOfHour(start_time) AS hour,
|
||||
uniq(id) AS countObservations,
|
||||
max(level != 'DEFAULT') AS hasLevel,
|
||||
max(provided_model_name IS NOT NULL) AS hasProvidedModelName,
|
||||
max(length(provided_usage_details) > 0) AS hasProvidedUsageDetails,
|
||||
max(length(provided_cost_details) > 0) AS hasProvidedCostDetails,
|
||||
max(prompt_name IS NOT NULL) AS hasPromptName
|
||||
FROM
|
||||
observations
|
||||
WHERE toStartOfHour(start_time) <= toStartOfHour(subtractHours(now(), 1))
|
||||
GROUP BY
|
||||
project_id,
|
||||
type,
|
||||
hour;
|
||||
@@ -0,0 +1 @@
|
||||
DROP VIEW IF EXISTS analytics_scores;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE VIEW analytics_scores AS
|
||||
SELECT
|
||||
project_id,
|
||||
toStartOfHour(timestamp) AS hour,
|
||||
uniq(id) AS countScores,
|
||||
max(source = 'ANNOTATION') AS hasAnnotation,
|
||||
max(source = 'API') AS hasApi,
|
||||
max(source = 'EVAL') AS hasEval,
|
||||
max(observation_id IS NOT NULL) AS hasObservationScore,
|
||||
max(session_id IS NOT NULL) AS hasSessionScore,
|
||||
max(dataset_run_id IS NOT NULL) AS hasDatasetRunScore,
|
||||
max(data_type = 'BOOLEAN') AS hasBoolScore,
|
||||
max(data_type = 'NUMERIC') AS hasNumericScore,
|
||||
max(data_type = 'CATEGORICAL') AS hasCategoricalScore,
|
||||
max(comment IS NOT NULL) AS hasComment
|
||||
FROM
|
||||
scores
|
||||
WHERE toStartOfHour(timestamp) <= toStartOfHour(subtractHours(now(), 1))
|
||||
GROUP BY
|
||||
project_id,
|
||||
hour;
|
||||
@@ -33,7 +33,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 65",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 70",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"db:migrate": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma migrate dev",
|
||||
"db:push": "DISABLE_ERD=false dotenv -e ../../.env -- npx prisma db push",
|
||||
@@ -60,7 +60,7 @@
|
||||
"@aws-sdk/lib-storage": "^3.675.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.679.0",
|
||||
"@azure/storage-blob": "^12.26.0",
|
||||
"@clickhouse/client": "^1.4.0",
|
||||
"@clickhouse/client": "^1.11.1",
|
||||
"@google-cloud/storage": "^7.15.2",
|
||||
"@langchain/anthropic": "^0.3.12",
|
||||
"@langchain/aws": "^0.1.3",
|
||||
@@ -91,7 +91,7 @@
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.15.0",
|
||||
"zod": "^3.23.8",
|
||||
"zod": "^3.24.4",
|
||||
"zod-to-json-schema": "^3.23.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -109,7 +109,8 @@ export const DashboardWidgetChartType = {
|
||||
BAR_TIME_SERIES: "BAR_TIME_SERIES",
|
||||
HORIZONTAL_BAR: "HORIZONTAL_BAR",
|
||||
VERTICAL_BAR: "VERTICAL_BAR",
|
||||
PIE: "PIE"
|
||||
PIE: "PIE",
|
||||
NUMBER: "NUMBER"
|
||||
} as const;
|
||||
export type DashboardWidgetChartType = (typeof DashboardWidgetChartType)[keyof typeof DashboardWidgetChartType];
|
||||
export type Account = {
|
||||
@@ -227,8 +228,8 @@ export type BlobStorageIntegration = {
|
||||
type: BlobStorageIntegrationType;
|
||||
bucket_name: string;
|
||||
prefix: string;
|
||||
access_key_id: string;
|
||||
secret_access_key: string;
|
||||
access_key_id: string | null;
|
||||
secret_access_key: string | null;
|
||||
region: string;
|
||||
endpoint: string | null;
|
||||
force_path_style: boolean;
|
||||
@@ -325,17 +326,29 @@ export type DatasetRuns = {
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
};
|
||||
export type EvalTemplate = {
|
||||
export type DefaultLlmModel = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string;
|
||||
llm_api_key_id: string;
|
||||
provider: string;
|
||||
adapter: string;
|
||||
model: string;
|
||||
model_params: unknown | null;
|
||||
};
|
||||
export type EvalTemplate = {
|
||||
id: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string | null;
|
||||
name: string;
|
||||
version: number;
|
||||
prompt: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
model_params: unknown;
|
||||
partner: string | null;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
model_params: unknown | null;
|
||||
vars: Generated<string[]>;
|
||||
output_schema: unknown;
|
||||
};
|
||||
@@ -361,6 +374,7 @@ export type JobExecution = {
|
||||
updated_at: Generated<Timestamp>;
|
||||
project_id: string;
|
||||
job_configuration_id: string;
|
||||
job_template_id: string | null;
|
||||
status: JobExecutionStatus;
|
||||
start_time: Timestamp | null;
|
||||
end_time: Timestamp | null;
|
||||
@@ -702,6 +716,7 @@ export type DB = {
|
||||
dataset_run_items: DatasetRunItems;
|
||||
dataset_runs: DatasetRuns;
|
||||
datasets: Dataset;
|
||||
default_llm_models: DefaultLlmModel;
|
||||
eval_templates: EvalTemplate;
|
||||
job_configurations: JobConfiguration;
|
||||
job_executions: JobExecution;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "trace_media_project_id_media_id_idx" ON "trace_media"("project_id", "media_id");
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "observation_media_project_id_media_id_idx" ON "observation_media"("project_id", "media_id");
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- Index cannot be created concurrently in a transaction, so we need to create it separately
|
||||
-- Prerequisite for migration 20250518075613_media_relax_id_uniqueness_to_project_only
|
||||
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "media_project_id_id_key" ON "media"("project_id", "id");
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `media` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- A unique constraint covering the columns `[project_id,id]` on the table `media` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "observation_media" DROP CONSTRAINT "observation_media_media_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "trace_media" DROP CONSTRAINT "trace_media_media_id_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "media" DROP CONSTRAINT "media_pkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "trace_media" ADD CONSTRAINT "trace_media_media_id_project_id_fkey" FOREIGN KEY ("media_id", "project_id") REFERENCES "media"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "observation_media" ADD CONSTRAINT "observation_media_media_id_project_id_fkey" FOREIGN KEY ("media_id", "project_id") REFERENCES "media"("id", "project_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/* Triple y and y_size in dashboards.definition->'widgets' */
|
||||
WITH updated AS (
|
||||
SELECT
|
||||
id,
|
||||
CASE
|
||||
WHEN jsonb_array_length(definition->'widgets') = 0 THEN definition
|
||||
ELSE jsonb_set(
|
||||
definition, -- original JSON
|
||||
'{widgets}', -- path to replace
|
||||
(
|
||||
SELECT jsonb_agg( -- rebuild widgets array
|
||||
/* 1) y * 3 2) y_size * 3 */
|
||||
jsonb_set(
|
||||
jsonb_set(widget, '{y}',
|
||||
to_jsonb( ((widget->>'y')::int * 3) ), /* y */
|
||||
true
|
||||
),
|
||||
'{y_size}',
|
||||
to_jsonb( ((widget->>'y_size')::int * 3) ),/* y_size */
|
||||
true
|
||||
)
|
||||
)
|
||||
FROM jsonb_array_elements(definition->'widgets') AS widget
|
||||
),
|
||||
true -- create path if absent
|
||||
)
|
||||
END AS new_def
|
||||
FROM dashboards
|
||||
)
|
||||
UPDATE dashboards d
|
||||
SET definition = u.new_def
|
||||
FROM updated u
|
||||
WHERE d.id = u.id;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "DashboardWidgetChartType" ADD VALUE 'NUMBER';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "observation_media_project_id_observation_id_idx";
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "default_llm_models" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"llm_api_key_id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"adapter" TEXT NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"model_params" JSONB,
|
||||
|
||||
CONSTRAINT "default_llm_models_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "default_llm_models" ADD CONSTRAINT "default_llm_models_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "default_llm_models" ADD CONSTRAINT "default_llm_models_llm_api_key_id_fkey" FOREIGN KEY ("llm_api_key_id") REFERENCES "llm_api_keys"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddUniqueConstraint
|
||||
ALTER TABLE "default_llm_models" ADD CONSTRAINT "default_llm_models_project_id_key" UNIQUE ("project_id");
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "eval_templates" ALTER COLUMN "project_id" DROP NOT NULL;
|
||||
ALTER TABLE "eval_templates" ALTER COLUMN "model" DROP NOT NULL;
|
||||
ALTER TABLE "eval_templates" ALTER COLUMN "provider" DROP NOT NULL;
|
||||
ALTER TABLE "eval_templates" ALTER COLUMN "model_params" DROP NOT NULL;
|
||||
ALTER TABLE "eval_templates" ADD COLUMN "partner" TEXT;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "job_executions" ADD COLUMN "job_template_id" TEXT;
|
||||
ALTER TABLE "job_executions" ADD CONSTRAINT "job_executions_job_template_id_fkey" FOREIGN KEY ("job_template_id") REFERENCES "eval_templates"("id") ON DELETE SET NULL;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "blob_storage_integrations" ALTER COLUMN "access_key_id" DROP NOT NULL,
|
||||
ALTER COLUMN "secret_access_key" DROP NOT NULL;
|
||||
@@ -157,6 +157,7 @@ model Project {
|
||||
Dashboard Dashboard[]
|
||||
DashboardWidget DashboardWidget[]
|
||||
TableViewPreset TableViewPreset[]
|
||||
DefaultLlmModel DefaultLlmModel[]
|
||||
|
||||
@@index([orgId])
|
||||
@@map("projects")
|
||||
@@ -222,8 +223,9 @@ model LlmApiKeys {
|
||||
extraHeaderKeys String[] @default([]) @map("extra_header_keys")
|
||||
config Json?
|
||||
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
DefaultLlmModel DefaultLlmModel[] @relation("LlmApiKeyId")
|
||||
|
||||
@@unique([projectId, provider])
|
||||
@@map("llm_api_keys")
|
||||
@@ -793,18 +795,22 @@ model EvalTemplate {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String? @map("project_id")
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
version Int
|
||||
prompt String
|
||||
model String
|
||||
provider String
|
||||
modelParams Json @map("model_params")
|
||||
name String
|
||||
version Int
|
||||
prompt String
|
||||
|
||||
partner String? // e.g. "ragas", describes the partner that created the template
|
||||
|
||||
model String?
|
||||
provider String?
|
||||
modelParams Json? @map("model_params")
|
||||
vars String[] @default([])
|
||||
outputSchema Json @map("output_schema")
|
||||
JobConfiguration JobConfiguration[]
|
||||
JobExecution JobExecution[]
|
||||
|
||||
@@unique([projectId, name, version])
|
||||
@@index([projectId, id])
|
||||
@@ -864,6 +870,9 @@ model JobExecution {
|
||||
jobConfigurationId String @map("job_configuration_id")
|
||||
jobConfiguration JobConfiguration @relation(fields: [jobConfigurationId], references: [id], onDelete: Cascade)
|
||||
|
||||
jobTemplateId String? @map("job_template_id")
|
||||
jobTemplate EvalTemplate? @relation(fields: [jobTemplateId], references: [id], onDelete: SetNull)
|
||||
|
||||
status JobExecutionStatus
|
||||
startTime DateTime? @map("start_time")
|
||||
endTime DateTime? @map("end_time")
|
||||
@@ -887,6 +896,26 @@ model JobExecution {
|
||||
@@map("job_executions")
|
||||
}
|
||||
|
||||
model DefaultLlmModel {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
projectId String @map("project_id")
|
||||
Project Project @relation(fields: [projectId], references: [id])
|
||||
|
||||
llmApiKeyId String @map("llm_api_key_id")
|
||||
LlmApiKey LlmApiKeys @relation("LlmApiKeyId", fields: [llmApiKeyId], references: [id], onDelete: Cascade)
|
||||
|
||||
provider String
|
||||
adapter String // This controls the interface that is used to connect with the LLM, e.g. 'openai' or 'anthropic'
|
||||
model String
|
||||
modelParams Json? @map("model_params")
|
||||
|
||||
@@unique([projectId])
|
||||
@@map("default_llm_models")
|
||||
}
|
||||
|
||||
// Single Sign-On configuration for a domain
|
||||
// This feature is part of the Enterprise Edition
|
||||
model SsoConfig {
|
||||
@@ -922,8 +951,8 @@ model BlobStorageIntegration {
|
||||
type BlobStorageIntegrationType @map("type")
|
||||
bucketName String @map("bucket_name")
|
||||
prefix String @map("prefix")
|
||||
accessKeyId String @map("access_key_id")
|
||||
secretAccessKey String @map("secret_access_key")
|
||||
accessKeyId String? @map("access_key_id")
|
||||
secretAccessKey String? @map("secret_access_key")
|
||||
region String @map("region")
|
||||
endpoint String? @map("endpoint")
|
||||
forcePathStyle Boolean @map("force_path_style")
|
||||
@@ -982,7 +1011,7 @@ model BatchExport {
|
||||
}
|
||||
|
||||
model Media {
|
||||
id String @id @default(cuid())
|
||||
id String
|
||||
sha256Hash String @map("sha_256_hash") @db.Char(44)
|
||||
projectId String @map("project_id")
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
@@ -998,6 +1027,7 @@ model Media {
|
||||
TraceMedia TraceMedia[]
|
||||
ObservationMedia ObservationMedia[]
|
||||
|
||||
@@unique([projectId, id])
|
||||
@@unique([projectId, sha256Hash])
|
||||
@@map("media")
|
||||
}
|
||||
@@ -1009,11 +1039,12 @@ model TraceMedia {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
mediaId String @map("media_id")
|
||||
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
|
||||
media Media @relation(fields: [mediaId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
traceId String @map("trace_id")
|
||||
field String @map("field")
|
||||
|
||||
@@unique([projectId, traceId, mediaId, field])
|
||||
@@index([projectId, mediaId])
|
||||
@@map("trace_media")
|
||||
}
|
||||
|
||||
@@ -1024,13 +1055,13 @@ model ObservationMedia {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
mediaId String @map("media_id")
|
||||
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
|
||||
media Media @relation(fields: [mediaId, projectId], references: [id, projectId], onDelete: Cascade)
|
||||
traceId String @map("trace_id")
|
||||
observationId String @map("observation_id")
|
||||
field String @map("field")
|
||||
|
||||
@@unique([projectId, traceId, observationId, mediaId, field])
|
||||
@@index([projectId, observationId])
|
||||
@@index([projectId, mediaId])
|
||||
@@map("observation_media")
|
||||
}
|
||||
|
||||
@@ -1124,6 +1155,7 @@ enum DashboardWidgetChartType {
|
||||
HORIZONTAL_BAR
|
||||
VERTICAL_BAR
|
||||
PIE
|
||||
NUMBER
|
||||
}
|
||||
|
||||
model DashboardWidget {
|
||||
|
||||
@@ -496,7 +496,7 @@ async function createDashboardsAndWidgets(projects: Project[]) {
|
||||
x: 0,
|
||||
y: 0,
|
||||
x_size: 6,
|
||||
y_size: 2,
|
||||
y_size: 6,
|
||||
},
|
||||
{
|
||||
type: "widget",
|
||||
@@ -505,7 +505,7 @@ async function createDashboardsAndWidgets(projects: Project[]) {
|
||||
x: 6,
|
||||
y: 0,
|
||||
x_size: 6,
|
||||
y_size: 2,
|
||||
y_size: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -38,6 +38,8 @@ const EnvSchema = z.object({
|
||||
CLICKHOUSE_DB: z.string().default("default"),
|
||||
CLICKHOUSE_USER: z.string(),
|
||||
CLICKHOUSE_PASSWORD: z.string(),
|
||||
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL: z.coerce.number().int().default(9000),
|
||||
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(25),
|
||||
|
||||
LANGFUSE_INGESTION_QUEUE_DELAY_MS: z.coerce
|
||||
.number()
|
||||
@@ -48,6 +50,12 @@ const EnvSchema = z.object({
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
.optional(),
|
||||
LANGFUSE_LOG_FORMAT: z.enum(["text", "json"]).default("text"),
|
||||
LANGFUSE_LOG_PROPAGATED_HEADERS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
@@ -63,6 +71,8 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SSE: z.enum(["AES256", "aws:kms"]).optional(),
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID: z.string().optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: z.string().optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: z.string().default(""),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION: z.string().optional(),
|
||||
@@ -72,6 +82,8 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SSE: z.enum(["AES256", "aws:kms"]).optional(),
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SSE_KMS_KEY_ID: z.string().optional(),
|
||||
LANGFUSE_USE_AZURE_BLOB: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_USE_GOOGLE_CLOUD_STORAGE: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_GOOGLE_CLOUD_STORAGE_CREDENTIALS: z.string().optional(),
|
||||
@@ -80,6 +92,8 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_SSE: z.enum(["AES256", "aws:kms"]).optional(),
|
||||
LANGFUSE_S3_CORE_DATA_EXPORT_SSE_KMS_KEY_ID: z.string().optional(),
|
||||
LANGFUSE_POSTGRES_METERING_DATA_EXPORT_IS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
@@ -90,6 +104,7 @@ const EnvSchema = z.object({
|
||||
LANGFUSE_API_TRACE_OBSERVATIONS_SIZE_LIMIT_BYTES: z.coerce
|
||||
.number()
|
||||
.default(80e6), // 80MB
|
||||
LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS: z.coerce.number().default(240_000), // 4 minutes
|
||||
});
|
||||
|
||||
export const env: z.infer<typeof EnvSchema> =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod";
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { BatchExportTableName } from "../batchExport/types";
|
||||
import { BatchTableNames } from "../../interfaces/tableNames";
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum BatchActionType {
|
||||
@@ -29,11 +29,11 @@ export const CreateBatchActionSchema = z.object({
|
||||
actionId: ActionIdSchema,
|
||||
targetId: z.string().optional(),
|
||||
query: BatchActionQuerySchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
tableName: z.nativeEnum(BatchTableNames),
|
||||
});
|
||||
|
||||
export const GetIsBatchActionInProgressSchema = z.object({
|
||||
projectId: z.string(),
|
||||
actionId: ActionIdSchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
tableName: z.nativeEnum(BatchTableNames),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BatchExport } from "@prisma/client";
|
||||
|
||||
import { singleFilter } from "../../interfaces/filters";
|
||||
import { orderBy } from "../../interfaces/orderBy";
|
||||
import { BatchTableNames } from "../../interfaces/tableNames";
|
||||
|
||||
export enum BatchExportStatus {
|
||||
QUEUED = "QUEUED",
|
||||
@@ -18,13 +19,9 @@ export enum BatchExportFileFormat {
|
||||
JSONL = "JSONL",
|
||||
}
|
||||
|
||||
export enum BatchExportTableName {
|
||||
Scores = "scores",
|
||||
Sessions = "sessions",
|
||||
Traces = "traces",
|
||||
Observations = "observations",
|
||||
DatasetRunItems = "dataset_run_items",
|
||||
}
|
||||
// Use shared BatchTableNames enum for consistency across batch operations
|
||||
// Keep BatchExportTableName as alias for backward compatibility
|
||||
export { BatchTableNames as BatchExportTableName };
|
||||
|
||||
export const exportOptions: Record<
|
||||
BatchExportFileFormat,
|
||||
@@ -44,7 +41,7 @@ export const exportOptions: Record<
|
||||
} as const;
|
||||
|
||||
export const BatchExportQuerySchema = z.object({
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
tableName: z.nativeEnum(BatchTableNames),
|
||||
filter: z.array(singleFilter).nullable(),
|
||||
orderBy,
|
||||
limit: z.number().optional(),
|
||||
|
||||
@@ -14,7 +14,6 @@ export const planLabels = {
|
||||
"cloud:pro": "Pro",
|
||||
"cloud:team": "Team",
|
||||
"cloud:enterprise": "Enterprise",
|
||||
"self-hosted:pro": "Pro (self-hosted)",
|
||||
"self-hosted:enterprise": "Enterprise (self-hosted)",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ export const langfuseObjects = [
|
||||
"dataset_item",
|
||||
] as const;
|
||||
|
||||
const langfuseObject = z.enum(langfuseObjects);
|
||||
export type LangfuseEvaluationObject = z.infer<typeof langfuseObject>;
|
||||
|
||||
// variable mapping stored in the db for eval templates
|
||||
export const variableMapping = z
|
||||
.object({
|
||||
@@ -15,7 +18,7 @@ export const variableMapping = z
|
||||
// name of the observation to extract the variable from
|
||||
// not required for trace, as we only have one.
|
||||
objectName: z.string().nullish(),
|
||||
langfuseObject: z.enum(langfuseObjects),
|
||||
langfuseObject: langfuseObject,
|
||||
selectedColumnId: z.string(),
|
||||
jsonSelector: z.string().nullish(),
|
||||
})
|
||||
@@ -31,7 +34,7 @@ export const variableMappingList = z.array(variableMapping);
|
||||
export const wipVariableMapping = z.object({
|
||||
templateVariable: z.string(),
|
||||
objectName: z.string().nullish(),
|
||||
langfuseObject: z.enum(langfuseObjects),
|
||||
langfuseObject: langfuseObject,
|
||||
selectedColumnId: z.string().nullish(),
|
||||
jsonSelector: z.string().nullish(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import z from "zod";
|
||||
import { variableMapping } from "./types";
|
||||
|
||||
/**
|
||||
* Parses an unknown value to a string representation
|
||||
* This is used for any evaluation variable that needs string conversion
|
||||
*/
|
||||
export const parseUnknownToString = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value === "symbol") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
|
||||
// Front-end friendly JSON path extraction
|
||||
const parsedJson =
|
||||
typeof selectedColumn === "string"
|
||||
? JSON.parse(selectedColumn)
|
||||
: selectedColumn;
|
||||
|
||||
// Simple path extraction (could use a library)
|
||||
return jsonSelector
|
||||
.split(".")
|
||||
.reduce((o, key) => (o as any)?.[key], parsedJson);
|
||||
}
|
||||
|
||||
export function extractValueFromObject(
|
||||
obj: Record<string, unknown>,
|
||||
mapping: z.infer<typeof variableMapping>,
|
||||
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown,
|
||||
): string {
|
||||
const selectedColumn = obj[mapping.selectedColumnId];
|
||||
const jsonParser = parseJson || parseJsonDefault;
|
||||
|
||||
let jsonSelectedColumn;
|
||||
if (mapping.jsonSelector && selectedColumn) {
|
||||
try {
|
||||
jsonSelectedColumn = jsonParser(selectedColumn, mapping.jsonSelector);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error parsing JSON selector: ${mapping.jsonSelector}`,
|
||||
error,
|
||||
);
|
||||
jsonSelectedColumn = selectedColumn;
|
||||
}
|
||||
} else {
|
||||
jsonSelectedColumn = selectedColumn;
|
||||
}
|
||||
|
||||
return parseUnknownToString(jsonSelectedColumn);
|
||||
}
|
||||
@@ -22,10 +22,11 @@ export * from "./server/llm/types";
|
||||
|
||||
// evals
|
||||
export * from "./features/evals/types";
|
||||
|
||||
export * from "./features/evals/utilities";
|
||||
// table actions
|
||||
export * from "./features/batchExport/types";
|
||||
export * from "./features/batchAction/types";
|
||||
export { BatchTableNames } from "./interfaces/tableNames";
|
||||
|
||||
// annotation
|
||||
export * from "./features/annotation/types";
|
||||
|
||||
@@ -5,8 +5,10 @@ export const RateLimitResource = z.enum([
|
||||
"ingestion",
|
||||
"public-api",
|
||||
"public-api-metrics",
|
||||
"public-api-daily-metrics-legacy",
|
||||
"prompts",
|
||||
"legacy-ingestion",
|
||||
"datasets",
|
||||
]);
|
||||
|
||||
// result of the rate limit check.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Shared table names used across batch operations (exports, actions, etc.)
|
||||
* This enum provides a centralized definition of database table names
|
||||
* to avoid coupling between different batch operation types.
|
||||
*/
|
||||
export enum BatchTableNames {
|
||||
Scores = "scores",
|
||||
Sessions = "sessions",
|
||||
Traces = "traces",
|
||||
Observations = "observations",
|
||||
DatasetRunItems = "dataset_run_items",
|
||||
}
|
||||
@@ -55,4 +55,5 @@ export type ApiAccessScope = {
|
||||
plan: Plan;
|
||||
rateLimitOverrides: z.infer<typeof CloudConfigRateLimit>;
|
||||
apiKeyId: string;
|
||||
publicKey: string;
|
||||
};
|
||||
|
||||
@@ -45,9 +45,9 @@ export class ClickHouseClientManager {
|
||||
database: env.CLICKHOUSE_DB,
|
||||
http_headers: opts?.http_headers,
|
||||
settings: opts?.clickhouse_settings,
|
||||
request_timeout: opts?.request_timeout ?? 30000,
|
||||
// Include any other relevant config options
|
||||
};
|
||||
|
||||
return JSON.stringify(keyParams);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ export class ClickHouseClientManager {
|
||||
opts: NodeClickHouseClientConfigOptions,
|
||||
): ClickhouseClientType {
|
||||
const key = this.generateClientSettingsKey(opts);
|
||||
|
||||
if (!this.clientMap.has(key)) {
|
||||
const headers = opts?.http_headers ?? {};
|
||||
const activeSpan = getCurrentSpan();
|
||||
@@ -84,12 +83,25 @@ export class ClickHouseClientManager {
|
||||
password: env.CLICKHOUSE_PASSWORD,
|
||||
database: env.CLICKHOUSE_DB,
|
||||
http_headers: headers,
|
||||
keep_alive: {
|
||||
idle_socket_ttl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL,
|
||||
},
|
||||
max_open_connections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
|
||||
clickhouse_settings: {
|
||||
...cloudOptions,
|
||||
...opts.clickhouse_settings,
|
||||
async_insert: 1,
|
||||
wait_for_async_insert: 1, // if disabled, we won't get errors from clickhouse
|
||||
...(opts.request_timeout && opts.request_timeout > 30000
|
||||
? {
|
||||
send_progress_in_http_headers: 1,
|
||||
http_headers_progress_interval_ms: "25000", // UInt64, should be passed as a string
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(opts.request_timeout
|
||||
? { request_timeout: opts.request_timeout }
|
||||
: {}),
|
||||
});
|
||||
|
||||
this.clientMap.set(key, client);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as opentelemetry from "@opentelemetry/api";
|
||||
import type { IncomingHttpHeaders } from "http";
|
||||
import { env } from "../env";
|
||||
|
||||
export type LangfuseContextProps = {
|
||||
headers?: IncomingHttpHeaders;
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a new context containing baggage entries composed from
|
||||
* the supplied props (headers, userId, projectId). Existing baggage
|
||||
* entries are preserved.
|
||||
*/
|
||||
export const contextWithLangfuseProps = (
|
||||
props: LangfuseContextProps,
|
||||
): opentelemetry.Context => {
|
||||
const ctx = opentelemetry.context.active();
|
||||
let baggage =
|
||||
opentelemetry.propagation.getBaggage(ctx) ??
|
||||
opentelemetry.propagation.createBaggage();
|
||||
|
||||
if (props.headers) {
|
||||
(env.LANGFUSE_LOG_PROPAGATED_HEADERS as string[]).forEach((name) => {
|
||||
const value = props.headers![name];
|
||||
if (!value) return;
|
||||
const strValue = Array.isArray(value) ? JSON.stringify(value) : value;
|
||||
baggage = baggage.setEntry(`langfuse.header.${name}`, {
|
||||
value: strValue,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (props.userId) {
|
||||
baggage = baggage.setEntry("langfuse.user.id", { value: props.userId });
|
||||
}
|
||||
if (props.projectId) {
|
||||
baggage = baggage.setEntry("langfuse.project.id", {
|
||||
value: props.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
return opentelemetry.propagation.setBaggage(ctx, baggage);
|
||||
};
|
||||
@@ -41,13 +41,14 @@ export * from "./redis/dataRetentionProcessingQueue";
|
||||
export * from "./redis/coreDataS3ExportQueue";
|
||||
export * from "./redis/meteringDataPostgresExportQueue";
|
||||
export * from "./redis/experimentCreateQueue";
|
||||
export * from "./redis/dlxRetryQueue";
|
||||
export * from "./redis/dlqRetryQueue";
|
||||
export * from "./auth/types";
|
||||
export * from "./queues";
|
||||
export * from "./orderByToPrisma";
|
||||
export * from "./filterToPrisma";
|
||||
export * from "./instrumentation";
|
||||
export * from "./logger";
|
||||
export * from "./headerPropagation";
|
||||
export * from "./queries";
|
||||
export * from "./repositories";
|
||||
export * from "./redis/evalExecutionQueue";
|
||||
@@ -55,6 +56,7 @@ export * from "./services/sessions-ui-table-service";
|
||||
export * from "./services/datasets-ui-table-service";
|
||||
export * from "./services/DashboardService";
|
||||
export * from "./services/TableViewService";
|
||||
export * from "./services/DefaultEvaluationModelService";
|
||||
|
||||
export * from "./data-deletion/ingestionFileDeletion";
|
||||
export * from "./s3";
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getClickhouseEntityType } from "../clickhouse/schemaUtils";
|
||||
import {
|
||||
getCurrentSpan,
|
||||
instrumentAsync,
|
||||
instrumentSync,
|
||||
recordDistribution,
|
||||
recordIncrement,
|
||||
traceException,
|
||||
@@ -39,6 +38,8 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
|
||||
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
|
||||
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
|
||||
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
|
||||
awsSse: env.LANGFUSE_S3_EVENT_UPLOAD_SSE,
|
||||
awsSseKmsKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID,
|
||||
});
|
||||
}
|
||||
return s3StorageServiceClient;
|
||||
@@ -78,11 +79,13 @@ const getDelay = (delay: number | null) => {
|
||||
* @param input - Batch of IngestionEventType. Will validate the types first thing and return errors if they are invalid.
|
||||
* @param authCheck - AuthHeaderValidVerificationResult
|
||||
* @param delay - (Optional) Delay in ms to wait before processing events in the batch.
|
||||
* @param source - (Optional) Source of the events for metrics tracking (e.g., "otel", "api").
|
||||
*/
|
||||
export const processEventBatch = async (
|
||||
input: unknown[],
|
||||
authCheck: AuthHeaderValidVerificationResult,
|
||||
delay: number | null = null,
|
||||
source: "api" | "otel" = "api",
|
||||
): Promise<{
|
||||
successes: { id: string; status: number }[];
|
||||
errors: {
|
||||
@@ -94,8 +97,10 @@ export const processEventBatch = async (
|
||||
}> => {
|
||||
// add context of api call to the span
|
||||
const currentSpan = getCurrentSpan();
|
||||
recordIncrement("langfuse.ingestion.event", input.length);
|
||||
recordDistribution("langfuse.ingestion.event_distribution", input.length);
|
||||
recordIncrement("langfuse.ingestion.event", input.length, { source });
|
||||
recordDistribution("langfuse.ingestion.event_distribution", input.length, {
|
||||
source,
|
||||
});
|
||||
|
||||
currentSpan?.setAttribute("langfuse.ingestion.batch_size", input.length);
|
||||
currentSpan?.setAttribute(
|
||||
@@ -117,19 +122,7 @@ export const processEventBatch = async (
|
||||
|
||||
const batch: z.infer<typeof ingestionEvent>[] = input
|
||||
.flatMap((event) => {
|
||||
const parsed = instrumentSync(
|
||||
{ name: "ingestion-zod-parse-individual-event" },
|
||||
(span) => {
|
||||
const parsedBody = ingestionEvent.safeParse(event);
|
||||
if (parsedBody.data?.id !== undefined) {
|
||||
span.setAttribute(
|
||||
"langfuse.ingestion.entity.id",
|
||||
parsedBody.data.id,
|
||||
);
|
||||
}
|
||||
return parsedBody;
|
||||
},
|
||||
);
|
||||
const parsed = ingestionEvent.safeParse(event);
|
||||
if (!parsed.success) {
|
||||
validationErrors.push({
|
||||
id:
|
||||
@@ -251,6 +244,11 @@ export const processEventBatch = async (
|
||||
type: sortedBatchByEventBodyId[id].type,
|
||||
eventBodyId: sortedBatchByEventBodyId[id].eventBodyId,
|
||||
fileKey: sortedBatchByEventBodyId[id].key,
|
||||
skipS3List:
|
||||
source === "otel" &&
|
||||
getClickhouseEntityType(
|
||||
sortedBatchByEventBodyId[id].type,
|
||||
) === "observation",
|
||||
},
|
||||
authCheck: authCheck as {
|
||||
validKey: true;
|
||||
|
||||
@@ -43,6 +43,14 @@ export async function instrumentAsync<T>(
|
||||
},
|
||||
activeContext,
|
||||
async (span) => {
|
||||
const baggage = opentelemetry.propagation.getBaggage(
|
||||
opentelemetry.context.active(),
|
||||
);
|
||||
if (baggage) {
|
||||
baggage
|
||||
.getAllEntries()
|
||||
.forEach(([k, v]) => span.setAttribute(k, v.value));
|
||||
}
|
||||
try {
|
||||
const result = await callback(span);
|
||||
span.end();
|
||||
@@ -77,6 +85,14 @@ export function instrumentSync<T>(
|
||||
},
|
||||
activeContext,
|
||||
(span) => {
|
||||
const baggage = opentelemetry.propagation.getBaggage(
|
||||
opentelemetry.context.active(),
|
||||
);
|
||||
if (baggage) {
|
||||
baggage
|
||||
.getAllEntries()
|
||||
.forEach(([k, v]) => span.setAttribute(k, v.value));
|
||||
}
|
||||
try {
|
||||
const result = callback(span);
|
||||
span.end();
|
||||
@@ -141,30 +157,6 @@ export const traceException = (
|
||||
});
|
||||
};
|
||||
|
||||
export const addUserToSpan = (
|
||||
attributes: {
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
email?: string;
|
||||
orgId?: string;
|
||||
plan?: string;
|
||||
},
|
||||
span?: opentelemetry.Span,
|
||||
) => {
|
||||
const activeSpan = span ?? getCurrentSpan();
|
||||
|
||||
if (!activeSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
attributes.userId && activeSpan.setAttribute("user.id", attributes.userId);
|
||||
attributes.email && activeSpan.setAttribute("user.email", attributes.email);
|
||||
attributes.projectId &&
|
||||
activeSpan.setAttribute("project.id", attributes.projectId);
|
||||
attributes.orgId && activeSpan.setAttribute("org.id", attributes.orgId);
|
||||
attributes.plan && activeSpan.setAttribute("org.plan", attributes.plan);
|
||||
};
|
||||
|
||||
export const getTracer = (name: string) => opentelemetry.trace.getTracer(name);
|
||||
|
||||
const cloudWatchClient = new CloudWatchClient();
|
||||
|
||||
@@ -71,7 +71,13 @@ export const OpenAIToolCallSchema = z.object({
|
||||
z.record(z.string(), z.unknown()),
|
||||
z
|
||||
.string()
|
||||
.transform((v) => JSON.parse(v))
|
||||
.transform((v) => {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
})
|
||||
.pipe(z.record(z.string(), z.unknown())),
|
||||
]),
|
||||
}),
|
||||
@@ -297,6 +303,8 @@ export type OpenAIModel = (typeof openAIModels)[number];
|
||||
// NOTE: Update docs page when changing this! https://langfuse.com/docs/playground#openai-playground--anthropic-playground
|
||||
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
|
||||
export const anthropicModels = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
@@ -311,7 +319,8 @@ export const anthropicModels = [
|
||||
|
||||
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys
|
||||
export const vertexAIModels = [
|
||||
"gemini-2.5-pro-exp-03-25",
|
||||
"gemini-2.5-pro-preview-05-06",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
"gemini-2.0-pro-exp-02-05",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-001",
|
||||
@@ -324,7 +333,8 @@ export const vertexAIModels = [
|
||||
|
||||
// WARNING: The first entry in the array is chosen as the default model to add LLM API keys. Make sure it supports top_p, max_tokens and temperature.
|
||||
export const googleAIStudioModels = [
|
||||
"gemini-2.5-pro-exp-03-25",
|
||||
"gemini-2.5-pro-preview-05-06",
|
||||
"gemini-2.5-flash-preview-05-20",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-lite-preview",
|
||||
"gemini-2.0-flash-lite-preview-02-05",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { env } from "../env";
|
||||
import winston from "winston";
|
||||
import { getCurrentSpan } from "./instrumentation";
|
||||
import { propagation, context } from "@opentelemetry/api";
|
||||
|
||||
const tracingFormat = function () {
|
||||
return winston.format((info) => {
|
||||
@@ -13,6 +14,12 @@ const tracingFormat = function () {
|
||||
info["trace_id"] = traceId;
|
||||
info["span_id"] = spanId;
|
||||
}
|
||||
const baggage = propagation.getBaggage(context.active());
|
||||
if (baggage) {
|
||||
const headerObj: Record<string, string> = {};
|
||||
baggage.getAllEntries().forEach(([k, v]) => (headerObj[k] = v.value));
|
||||
if (Object.keys(headerObj).length) info = { ...headerObj, ...info };
|
||||
}
|
||||
return info;
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
BatchActionQuerySchema,
|
||||
BatchActionType,
|
||||
} from "../features/batchAction/types";
|
||||
import { BatchExportTableName } from "../features/batchExport/types";
|
||||
import { BatchTableNames } from "../interfaces/tableNames";
|
||||
|
||||
export const IngestionEvent = z.object({
|
||||
data: z.object({
|
||||
type: z.nativeEnum(eventTypes),
|
||||
eventBodyId: z.string(),
|
||||
fileKey: z.string().optional(),
|
||||
skipS3List: z.boolean().optional(),
|
||||
}),
|
||||
authCheck: z.object({
|
||||
validKey: z.literal(true),
|
||||
@@ -75,7 +76,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
|
||||
actionId: z.literal("score-delete"),
|
||||
projectId: z.string(),
|
||||
query: BatchActionQuerySchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
tableName: z.nativeEnum(BatchTableNames),
|
||||
cutoffCreatedAt: z.date(),
|
||||
targetId: z.string().optional(),
|
||||
type: z.nativeEnum(BatchActionType),
|
||||
@@ -84,7 +85,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
|
||||
actionId: z.literal("trace-delete"),
|
||||
projectId: z.string(),
|
||||
query: BatchActionQuerySchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
tableName: z.nativeEnum(BatchTableNames),
|
||||
cutoffCreatedAt: z.date(),
|
||||
targetId: z.string().optional(),
|
||||
type: z.nativeEnum(BatchActionType),
|
||||
@@ -93,7 +94,7 @@ export const BatchActionProcessingEventSchema = z.discriminatedUnion(
|
||||
actionId: z.literal("trace-add-to-annotation-queue"),
|
||||
projectId: z.string(),
|
||||
query: BatchActionQuerySchema,
|
||||
tableName: z.nativeEnum(BatchExportTableName),
|
||||
tableName: z.nativeEnum(BatchTableNames),
|
||||
cutoffCreatedAt: z.date(),
|
||||
targetId: z.string().optional(),
|
||||
type: z.nativeEnum(BatchActionType),
|
||||
@@ -119,6 +120,7 @@ export const CreateEvalQueueEventSchema = DatasetRunItemUpsertEventSchema.and(
|
||||
z.object({
|
||||
timestamp: z.date(),
|
||||
configId: z.string(),
|
||||
exactTimestamp: z.date().optional(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export class BatchActionQueue {
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 10_000,
|
||||
attempts: 2,
|
||||
attempts: 10,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 5000,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { DataRetentionProcessingQueue } from "./dataRetentionProcessingQueue";
|
||||
import { BatchActionQueue } from "./batchActionQueue";
|
||||
import { CreateEvalQueue } from "./createEvalQueue";
|
||||
import { ScoreDeleteQueue } from "./scoreDelete";
|
||||
import { DeadLetterRetryQueue } from "./dlxRetryQueue";
|
||||
import { DeadLetterRetryQueue } from "./dlqRetryQueue";
|
||||
|
||||
export function getQueue(queueName: QueueName): Queue | null {
|
||||
switch (queueName) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Redis, { RedisOptions } from "ioredis";
|
||||
import fs from 'fs';
|
||||
import fs from "fs";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
|
||||
@@ -10,8 +10,11 @@ const defaultRedisOptions: Partial<RedisOptions> = {
|
||||
|
||||
export const redisQueueRetryOptions: Partial<RedisOptions> = {
|
||||
retryStrategy: (times: number) => {
|
||||
if (times >= 5) {
|
||||
// A few retries are expected and no cause for action.
|
||||
logger.warn(`Connection to redis lost. Retry attempt: ${times}`);
|
||||
}
|
||||
// Retries forever. Waits at least 1s and at most 20s between retries.
|
||||
logger.warn(`Connection to redis lost. Retry attempt: ${times}`);
|
||||
return Math.max(Math.min(Math.exp(times), 20000), 1000);
|
||||
},
|
||||
reconnectOnError: (err) => {
|
||||
@@ -29,9 +32,15 @@ export const createNewRedisInstance = (
|
||||
const tlsOptions = tlsEnabled
|
||||
? {
|
||||
tls: {
|
||||
ca: env.REDIS_TLS_CA_PATH ? fs.readFileSync(env.REDIS_TLS_CA_PATH) : undefined,
|
||||
cert: env.REDIS_TLS_CERT_PATH ? fs.readFileSync(env.REDIS_TLS_CERT_PATH) : undefined,
|
||||
key: env.REDIS_TLS_KEY_PATH ? fs.readFileSync(env.REDIS_TLS_KEY_PATH) : undefined,
|
||||
ca: env.REDIS_TLS_CA_PATH
|
||||
? fs.readFileSync(env.REDIS_TLS_CA_PATH)
|
||||
: undefined,
|
||||
cert: env.REDIS_TLS_CERT_PATH
|
||||
? fs.readFileSync(env.REDIS_TLS_CERT_PATH)
|
||||
: undefined,
|
||||
key: env.REDIS_TLS_KEY_PATH
|
||||
? fs.readFileSync(env.REDIS_TLS_KEY_PATH)
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
@@ -25,6 +25,8 @@ const getS3StorageServiceClient = (bucketName: string): StorageService => {
|
||||
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
|
||||
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
|
||||
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
|
||||
awsSse: env.LANGFUSE_S3_EVENT_UPLOAD_SSE,
|
||||
awsSseKmsKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID,
|
||||
});
|
||||
}
|
||||
return s3StorageServiceClient;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SCORE_TO_TRACE_OBSERVATIONS_INTERVAL,
|
||||
} from "./constants";
|
||||
|
||||
export type DateTrunc = "year" | "month" | "week" | "day" | "hour" | "minute";
|
||||
export type DateTrunc = "month" | "week" | "day" | "hour" | "minute";
|
||||
|
||||
const extractEnvironmentFilterFromFilters = (
|
||||
filter: FilterState,
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
import { env } from "../../env";
|
||||
import { TracingSearchType } from "../../interfaces/search";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { ObservationType } from "../../domain";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
|
||||
/**
|
||||
* Checks if observation exists in clickhouse.
|
||||
@@ -207,9 +209,20 @@ export const getObservationsForTrace = async <IncludeIO extends boolean>(
|
||||
}
|
||||
}
|
||||
|
||||
return records.map((r) =>
|
||||
convertObservation({ ...r, metadata: r.metadata ?? {} }),
|
||||
);
|
||||
return records.map((r) => {
|
||||
const observation = convertObservation({
|
||||
...r,
|
||||
metadata: r.metadata ?? {},
|
||||
});
|
||||
recordDistribution(
|
||||
"langfuse.query_by_id_age",
|
||||
new Date().getTime() - observation.startTime.getTime(),
|
||||
{
|
||||
table: "observations",
|
||||
},
|
||||
);
|
||||
return observation;
|
||||
});
|
||||
};
|
||||
|
||||
export const getObservationForTraceIdByName = async (
|
||||
@@ -278,20 +291,40 @@ export const getObservationForTraceIdByName = async (
|
||||
return records.map(convertObservation);
|
||||
};
|
||||
|
||||
export const getObservationById = async (
|
||||
id: string,
|
||||
projectId: string,
|
||||
fetchWithInputOutput: boolean = false,
|
||||
startTime?: Date,
|
||||
) => {
|
||||
const records = await getObservationByIdInternal(
|
||||
export const getObservationById = async ({
|
||||
id,
|
||||
projectId,
|
||||
fetchWithInputOutput = false,
|
||||
startTime,
|
||||
type,
|
||||
traceId,
|
||||
}: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
fetchWithInputOutput?: boolean;
|
||||
startTime?: Date;
|
||||
type?: ObservationType;
|
||||
traceId?: string;
|
||||
}) => {
|
||||
const records = await getObservationByIdInternal({
|
||||
id,
|
||||
projectId,
|
||||
fetchWithInputOutput,
|
||||
startTime,
|
||||
);
|
||||
type,
|
||||
traceId,
|
||||
});
|
||||
const mapped = records.map(convertObservation);
|
||||
|
||||
mapped.forEach((observation) => {
|
||||
recordDistribution(
|
||||
"langfuse.query_by_id_age",
|
||||
new Date().getTime() - observation.startTime.getTime(),
|
||||
{
|
||||
table: "observations",
|
||||
},
|
||||
);
|
||||
});
|
||||
if (mapped.length === 0) {
|
||||
throw new LangfuseNotFoundError(`Observation with id ${id} not found`);
|
||||
}
|
||||
@@ -354,12 +387,21 @@ export const getObservationsById = async (
|
||||
return records.map(convertObservation);
|
||||
};
|
||||
|
||||
const getObservationByIdInternal = async (
|
||||
id: string,
|
||||
projectId: string,
|
||||
fetchWithInputOutput: boolean = false,
|
||||
startTime?: Date,
|
||||
) => {
|
||||
const getObservationByIdInternal = async ({
|
||||
id,
|
||||
projectId,
|
||||
fetchWithInputOutput = false,
|
||||
startTime,
|
||||
type,
|
||||
traceId,
|
||||
}: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
fetchWithInputOutput?: boolean;
|
||||
startTime?: Date;
|
||||
type?: ObservationType;
|
||||
traceId?: string;
|
||||
}) => {
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
@@ -395,6 +437,8 @@ const getObservationByIdInternal = async (
|
||||
WHERE id = {id: String}
|
||||
AND project_id = {projectId: String}
|
||||
${startTime ? `AND toDate(start_time) = toDate({startTime: DateTime64(3)})` : ""}
|
||||
${type ? `AND type = {type: String}` : ""}
|
||||
${traceId ? `AND trace_id = {traceId: String}` : ""}
|
||||
ORDER BY event_ts desc
|
||||
LIMIT 1 by id, project_id`;
|
||||
return await queryClickhouse<ObservationRecordReadType>({
|
||||
@@ -405,6 +449,7 @@ const getObservationByIdInternal = async (
|
||||
...(startTime
|
||||
? { startTime: convertDateToClickhouseDateTime(startTime) }
|
||||
: {}),
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -1022,7 +1067,7 @@ export const deleteObservationsByTraceIds = async (
|
||||
traceIds,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -1044,7 +1089,7 @@ export const deleteObservationsByProjectId = async (projectId: string) => {
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -1071,7 +1116,7 @@ export const deleteObservationsOlderThanDays = async (
|
||||
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { env } from "../../env";
|
||||
import { _handleGetScoreById, _handleGetScoresByIds } from "./scores-utils";
|
||||
import { parseMetadataCHRecordToDomain } from "../utils/metadata_conversion";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
|
||||
export const searchExistingAnnotationScore = async (
|
||||
projectId: string,
|
||||
@@ -348,6 +349,14 @@ export const getScoresForTraces = async <
|
||||
metadata: excludeMetadata ? {} : row.metadata,
|
||||
});
|
||||
|
||||
recordDistribution(
|
||||
"langfuse.query_by_id_age",
|
||||
new Date().getTime() - score.timestamp.getTime(),
|
||||
{
|
||||
table: "scores",
|
||||
},
|
||||
);
|
||||
|
||||
if (includeHasMetadata) {
|
||||
Object.assign(score, { hasMetadata: !!row.has_metadata });
|
||||
}
|
||||
@@ -922,6 +931,9 @@ export const deleteScores = async (projectId: string, scoreIds: string[]) => {
|
||||
projectId,
|
||||
scoreIds,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "score",
|
||||
@@ -969,7 +981,7 @@ export const deleteScoresByProjectId = async (projectId: string) => {
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -996,7 +1008,7 @@ export const deleteScoresOlderThanDays = async (
|
||||
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./constants";
|
||||
import { env } from "../../env";
|
||||
import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
import { recordDistribution } from "../instrumentation";
|
||||
|
||||
/**
|
||||
* Checks if trace exists in clickhouse.
|
||||
@@ -41,12 +42,21 @@ import { ClickHouseClientConfigOptions } from "@clickhouse/client";
|
||||
* • Filters within ±2 day window
|
||||
* • Used for validating trace references before eval job creation
|
||||
*/
|
||||
export const checkTraceExists = async (
|
||||
projectId: string,
|
||||
traceId: string,
|
||||
timestamp: Date,
|
||||
filter: FilterState,
|
||||
): Promise<boolean> => {
|
||||
export const checkTraceExists = async ({
|
||||
projectId,
|
||||
traceId,
|
||||
timestamp,
|
||||
filter,
|
||||
maxTimeStamp,
|
||||
exactTimestamp,
|
||||
}: {
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
timestamp: Date;
|
||||
filter: FilterState;
|
||||
maxTimeStamp: Date | undefined;
|
||||
exactTimestamp?: Date;
|
||||
}): Promise<boolean> => {
|
||||
const { tracesFilter } = getProjectIdDefaultFilter(projectId, {
|
||||
tracesPrefix: "t",
|
||||
});
|
||||
@@ -101,7 +111,9 @@ export const checkTraceExists = async (
|
||||
WHERE ${tracesFilterRes.query}
|
||||
AND t.project_id = {projectId: String}
|
||||
AND timestamp >= {timestamp: DateTime64(3)} - ${TRACE_TO_OBSERVATIONS_INTERVAL}
|
||||
AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY
|
||||
${maxTimeStamp ? `AND timestamp <= {maxTimeStamp: DateTime64(3)}` : ""}
|
||||
${!maxTimeStamp ? `AND timestamp <= {timestamp: DateTime64(3)} + INTERVAL 2 DAY` : ""}
|
||||
${exactTimestamp ? `AND timestamp = {exactTimestamp: DateTime64(3)}` : ""}
|
||||
GROUP BY t.id, t.project_id
|
||||
`;
|
||||
|
||||
@@ -114,6 +126,12 @@ export const checkTraceExists = async (
|
||||
...(timestamp
|
||||
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
|
||||
: {}),
|
||||
...(maxTimeStamp
|
||||
? { maxTimeStamp: convertDateToClickhouseDateTime(maxTimeStamp) }
|
||||
: {}),
|
||||
...(exactTimestamp
|
||||
? { exactTimestamp: convertDateToClickhouseDateTime(exactTimestamp) }
|
||||
: {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -208,7 +226,16 @@ export const getTracesBySessionId = async (
|
||||
},
|
||||
});
|
||||
|
||||
return records.map(convertClickhouseToDomain);
|
||||
const traces = records.map(convertClickhouseToDomain);
|
||||
|
||||
traces.forEach((trace) => {
|
||||
recordDistribution(
|
||||
"langfuse.traces_by_session_id_age",
|
||||
new Date().getTime() - trace.timestamp.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
return traces;
|
||||
};
|
||||
|
||||
export const hasAnyTrace = async (projectId: string) => {
|
||||
@@ -353,6 +380,16 @@ export const getTraceById = async ({
|
||||
|
||||
const res = records.map(convertClickhouseToDomain);
|
||||
|
||||
res.forEach((trace) => {
|
||||
recordDistribution(
|
||||
"langfuse.query_by_id_age",
|
||||
new Date().getTime() - trace.timestamp.getTime(),
|
||||
{
|
||||
table: "traces",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return res.shift();
|
||||
};
|
||||
|
||||
@@ -570,7 +607,7 @@ export const deleteTraces = async (projectId: string, traceIds: string[]) => {
|
||||
traceIds,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -597,7 +634,7 @@ export const deleteTracesOlderThanDays = async (
|
||||
cutoffDate: convertDateToClickhouseDateTime(beforeDate),
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -619,7 +656,7 @@ export const deleteTracesByProjectId = async (projectId: string) => {
|
||||
projectId,
|
||||
},
|
||||
clickhouseConfigs: {
|
||||
request_timeout: 120_000, // 2 minutes
|
||||
request_timeout: env.LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -1036,3 +1073,37 @@ export const traceWithSessionIdExists = async (
|
||||
|
||||
return result.length > 0;
|
||||
};
|
||||
|
||||
export async function getAgentGraphData(params: {
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
chMinStartTime: string;
|
||||
chMaxStartTime: string;
|
||||
}) {
|
||||
const { projectId, traceId, chMinStartTime, chMaxStartTime } = params;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
id,
|
||||
parent_observation_id,
|
||||
metadata['langgraph_node'] AS node,
|
||||
metadata['langgraph_step'] AS step
|
||||
FROM
|
||||
observations
|
||||
WHERE
|
||||
project_id = {projectId: String}
|
||||
AND trace_id = {traceId: String}
|
||||
AND start_time >= {chMinStartTime: DateTime64(3)}
|
||||
AND start_time <= {chMaxStartTime: DateTime64(3)}
|
||||
`;
|
||||
|
||||
return queryClickhouse({
|
||||
query,
|
||||
params: {
|
||||
traceId,
|
||||
projectId,
|
||||
chMinStartTime,
|
||||
chMaxStartTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export const getS3MediaStorageClient = (bucketName: string): StorageService => {
|
||||
endpoint: env.LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT,
|
||||
region: env.LANGFUSE_S3_MEDIA_UPLOAD_REGION,
|
||||
forcePathStyle: env.LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE === "true",
|
||||
awsSse: env.LANGFUSE_S3_MEDIA_UPLOAD_SSE,
|
||||
awsSseKmsKeyId: env.LANGFUSE_S3_MEDIA_UPLOAD_SSE_KMS_KEY_ID,
|
||||
});
|
||||
}
|
||||
return s3MediaStorageClient;
|
||||
@@ -30,6 +32,8 @@ export const getS3EventStorageClient = (bucketName: string): StorageService => {
|
||||
endpoint: env.LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT,
|
||||
region: env.LANGFUSE_S3_EVENT_UPLOAD_REGION,
|
||||
forcePathStyle: env.LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE === "true",
|
||||
awsSse: env.LANGFUSE_S3_EVENT_UPLOAD_SSE,
|
||||
awsSseKmsKeyId: env.LANGFUSE_S3_EVENT_UPLOAD_SSE_KMS_KEY_ID,
|
||||
});
|
||||
}
|
||||
return s3EventStorageClient;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "../../../db";
|
||||
import { LangfuseConflictError, type OrderByState } from "../../../";
|
||||
import {
|
||||
LangfuseConflictError,
|
||||
LangfuseNotFoundError,
|
||||
type OrderByState,
|
||||
} from "../../../";
|
||||
import {
|
||||
CreateWidgetInput,
|
||||
WidgetDomain,
|
||||
@@ -30,7 +34,7 @@ export class DashboardService {
|
||||
const [dashboards, totalCount] = await Promise.all([
|
||||
prisma.dashboard.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
OR: [{ projectId }, { projectId: null }],
|
||||
},
|
||||
orderBy: orderBy
|
||||
? [{ [orderBy.column]: orderBy.order.toLowerCase() }]
|
||||
@@ -40,13 +44,16 @@ export class DashboardService {
|
||||
}),
|
||||
prisma.dashboard.count({
|
||||
where: {
|
||||
projectId,
|
||||
OR: [{ projectId }, { projectId: null }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const domainDashboards = dashboards.map((dashboard) =>
|
||||
DashboardDomainSchema.parse(dashboard),
|
||||
DashboardDomainSchema.parse({
|
||||
...dashboard,
|
||||
owner: dashboard.projectId ? "PROJECT" : "LANGFUSE",
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -78,7 +85,10 @@ export class DashboardService {
|
||||
},
|
||||
});
|
||||
|
||||
return DashboardDomainSchema.parse(newDashboard);
|
||||
return DashboardDomainSchema.parse({
|
||||
...newDashboard,
|
||||
owner: newDashboard.projectId ? "PROJECT" : "LANGFUSE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +121,38 @@ export class DashboardService {
|
||||
},
|
||||
});
|
||||
|
||||
return DashboardDomainSchema.parse(updatedDashboard);
|
||||
return DashboardDomainSchema.parse({
|
||||
...updatedDashboard,
|
||||
owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a dashboard's name and description.
|
||||
*/
|
||||
public static async updateDashboard(
|
||||
dashboardId: string,
|
||||
projectId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
userId?: string,
|
||||
): Promise<DashboardDomain> {
|
||||
const updatedDashboard = await prisma.dashboard.update({
|
||||
where: {
|
||||
id: dashboardId,
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return DashboardDomainSchema.parse({
|
||||
...updatedDashboard,
|
||||
owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +165,7 @@ export class DashboardService {
|
||||
const dashboard = await prisma.dashboard.findFirst({
|
||||
where: {
|
||||
id: dashboardId,
|
||||
projectId,
|
||||
OR: [{ projectId }, { projectId: null }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,7 +173,10 @@ export class DashboardService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DashboardDomainSchema.parse(dashboard);
|
||||
return DashboardDomainSchema.parse({
|
||||
...dashboard,
|
||||
owner: dashboard.projectId ? "PROJECT" : "LANGFUSE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,7 +227,10 @@ export class DashboardService {
|
||||
]);
|
||||
|
||||
const domainWidgets = widgets.map((widget) =>
|
||||
WidgetDomainSchema.parse(widget),
|
||||
WidgetDomainSchema.parse({
|
||||
...widget,
|
||||
owner: widget.projectId ? "PROJECT" : "LANGFUSE",
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -216,11 +263,14 @@ export class DashboardService {
|
||||
},
|
||||
});
|
||||
|
||||
return WidgetDomainSchema.parse(newWidget);
|
||||
return WidgetDomainSchema.parse({
|
||||
...newWidget,
|
||||
owner: newWidget.projectId ? "PROJECT" : "LANGFUSE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a dashboard widget by ID.
|
||||
* Gets a dashboard widget by ID. Look either in the current project or in the Langfuse managed widgets.
|
||||
*/
|
||||
public static async getWidget(
|
||||
widgetId: string,
|
||||
@@ -229,7 +279,7 @@ export class DashboardService {
|
||||
const widget = await prisma.dashboardWidget.findFirst({
|
||||
where: {
|
||||
id: widgetId,
|
||||
projectId,
|
||||
OR: [{ projectId }, { projectId: null }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -237,7 +287,10 @@ export class DashboardService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return WidgetDomainSchema.parse(widget);
|
||||
return WidgetDomainSchema.parse({
|
||||
...widget,
|
||||
owner: widget.projectId ? "PROJECT" : "LANGFUSE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,20 +321,8 @@ export class DashboardService {
|
||||
});
|
||||
|
||||
return WidgetDomainSchema.parse({
|
||||
id: widgetId,
|
||||
projectId,
|
||||
createdAt: updatedWidget.createdAt,
|
||||
updatedAt: updatedWidget.updatedAt,
|
||||
createdBy: updatedWidget.createdBy,
|
||||
updatedBy: updatedWidget.updatedBy,
|
||||
name: updatedWidget.name,
|
||||
description: updatedWidget.description,
|
||||
view: updatedWidget.view,
|
||||
dimensions: updatedWidget.dimensions as WidgetDomain["dimensions"],
|
||||
metrics: updatedWidget.metrics as WidgetDomain["metrics"],
|
||||
filters: updatedWidget.filters as WidgetDomain["filters"],
|
||||
chartType: updatedWidget.chartType,
|
||||
chartConfig: updatedWidget.chartConfig as WidgetDomain["chartConfig"],
|
||||
...updatedWidget,
|
||||
owner: updatedWidget.projectId ? "PROJECT" : "LANGFUSE",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,4 +367,80 @@ export class DashboardService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a Langfuse-owned widget into the user project, rewires the specified dashboard placement to the new widget and returns the new widget id.
|
||||
*/
|
||||
public static async copyWidgetToProject(props: {
|
||||
sourceWidgetId: string;
|
||||
projectId: string;
|
||||
dashboardId: string;
|
||||
placementId: string;
|
||||
userId?: string;
|
||||
}): Promise<string> {
|
||||
const { sourceWidgetId, projectId, dashboardId, placementId, userId } =
|
||||
props;
|
||||
|
||||
const sourceWidget = await prisma.dashboardWidget.findFirst({
|
||||
where: {
|
||||
id: sourceWidgetId,
|
||||
projectId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!sourceWidget) {
|
||||
throw new LangfuseNotFoundError(
|
||||
`Source widget ${sourceWidgetId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate widget and update dashboard definition atomically
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// 1. create duplicate in project scope
|
||||
const newWidget = await tx.dashboardWidget.create({
|
||||
data: {
|
||||
name: sourceWidget.name,
|
||||
description: sourceWidget.description,
|
||||
view: sourceWidget.view,
|
||||
dimensions: sourceWidget.dimensions ?? [],
|
||||
metrics: sourceWidget.metrics ?? [],
|
||||
filters: sourceWidget.filters ?? [],
|
||||
chartType: sourceWidget.chartType,
|
||||
chartConfig: sourceWidget.chartConfig ?? {},
|
||||
projectId, // project owned
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. fetch dashboard to change reference
|
||||
const dashboard = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new LangfuseNotFoundError(
|
||||
`Dashboard ${dashboardId} not found in project ${projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const definition = (dashboard.definition ?? {
|
||||
widgets: [],
|
||||
}) as z.infer<typeof DashboardDefinitionSchema>;
|
||||
const updatedWidgets = (definition.widgets || []).map((w: any) =>
|
||||
w.id === placementId ? { ...w, widgetId: newWidget.id } : w,
|
||||
);
|
||||
|
||||
// 3. update dashboard with new widget reference
|
||||
await tx.dashboard.update({
|
||||
where: { id: dashboardId, projectId },
|
||||
data: {
|
||||
updatedBy: userId,
|
||||
definition: { widgets: updatedWidgets },
|
||||
},
|
||||
});
|
||||
|
||||
return newWidget.id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ export const PieChartConfig = BaseTotalValueChartConfig.extend({
|
||||
type: z.literal("PIE"),
|
||||
});
|
||||
|
||||
export const BigNumberChartConfig = BaseTotalValueChartConfig.extend({
|
||||
type: z.literal("NUMBER"),
|
||||
});
|
||||
|
||||
// Define dimension schema
|
||||
export const DimensionSchema = z.object({
|
||||
field: z.string(),
|
||||
@@ -42,6 +46,7 @@ export const ChartConfigSchema = z.discriminatedUnion("type", [
|
||||
HorizontalBarChartConfig,
|
||||
VerticalBarChartConfig,
|
||||
PieChartConfig,
|
||||
BigNumberChartConfig,
|
||||
]);
|
||||
|
||||
export const DashboardDefinitionWidgetWidgetSchema = z.object({
|
||||
@@ -62,6 +67,8 @@ export const DashboardDefinitionSchema = z.object({
|
||||
widgets: z.array(DashboardDefinitionWidgetSchema),
|
||||
});
|
||||
|
||||
export const OwnerEnum = z.enum(["PROJECT", "LANGFUSE"]);
|
||||
|
||||
// Define the dashboard domain object
|
||||
export const DashboardDomainSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -73,6 +80,7 @@ export const DashboardDomainSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
definition: DashboardDefinitionSchema,
|
||||
owner: OwnerEnum,
|
||||
});
|
||||
|
||||
// Define the dashboard list response
|
||||
@@ -97,6 +105,7 @@ export const WidgetDomainSchema = z.object({
|
||||
filters: z.array(singleFilter),
|
||||
chartType: z.nativeEnum(DashboardWidgetChartType),
|
||||
chartConfig: ChartConfigSchema,
|
||||
owner: OwnerEnum,
|
||||
});
|
||||
|
||||
// Define create widget input schema
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import z from "zod";
|
||||
import { prisma } from "../../../db";
|
||||
import { LangfuseNotFoundError } from "../../../errors";
|
||||
import { LLMApiKeySchema, ZodModelConfig } from "../../llm/types";
|
||||
|
||||
type ValidConfig = {
|
||||
provider: string;
|
||||
model: string;
|
||||
modelParams: z.infer<typeof ZodModelConfig>;
|
||||
};
|
||||
|
||||
export class DefaultEvalModelService {
|
||||
/**
|
||||
* Gets the default evaluation model for a project
|
||||
*/
|
||||
public static async fetchDefaultModel(projectId: string) {
|
||||
return prisma.defaultLlmModel.findUnique({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a default evaluation model for a project
|
||||
*/
|
||||
public static async upsertDefaultModel(params: {
|
||||
projectId: string;
|
||||
provider: string;
|
||||
adapter: string;
|
||||
model: string;
|
||||
modelParams?: z.infer<typeof ZodModelConfig>;
|
||||
}) {
|
||||
const { projectId, provider, adapter, model, modelParams } = params;
|
||||
|
||||
// Find the LLM API key for the provider
|
||||
const llmApiKey = await prisma.llmApiKeys.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
provider,
|
||||
},
|
||||
});
|
||||
|
||||
if (!llmApiKey) {
|
||||
throw new LangfuseNotFoundError(
|
||||
`API key for provider ${provider} in project ${projectId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create or update the default model
|
||||
return prisma.defaultLlmModel.upsert({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
update: {
|
||||
llmApiKeyId: llmApiKey.id,
|
||||
provider,
|
||||
adapter,
|
||||
model,
|
||||
modelParams: modelParams ? modelParams : undefined,
|
||||
},
|
||||
create: {
|
||||
projectId,
|
||||
llmApiKeyId: llmApiKey.id,
|
||||
provider,
|
||||
adapter,
|
||||
model,
|
||||
modelParams: modelParams ? modelParams : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple validation that can also be used client side
|
||||
* Validates if the provider, model, and model parameters are valid
|
||||
*/
|
||||
public static validateModelConfig(config: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
modelParams?: unknown;
|
||||
}): config is ValidConfig {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.provider || !config.model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate model parameters
|
||||
if (config.modelParams) {
|
||||
const result = ZodModelConfig.safeParse(config.modelParams);
|
||||
if (!result.success) {
|
||||
errors.push(
|
||||
...result.error.errors.map(
|
||||
(err) => `Model parameter error: ${err.message}`,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model config against available API keys
|
||||
* Checks if the provider and model are valid and an API key exists
|
||||
*/
|
||||
public static async fetchValidModelConfig(
|
||||
projectId: string,
|
||||
provider?: string,
|
||||
model?: string,
|
||||
modelParams?: unknown,
|
||||
): Promise<
|
||||
| {
|
||||
valid: true;
|
||||
config: {
|
||||
provider: string;
|
||||
model: string;
|
||||
modelParams?: z.infer<typeof ZodModelConfig>;
|
||||
apiKey: z.infer<typeof LLMApiKeySchema>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
valid: false;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
let selectedModel: ValidConfig | null = null;
|
||||
// Basic validation first
|
||||
const config = {
|
||||
provider,
|
||||
model,
|
||||
modelParams,
|
||||
};
|
||||
const basicValidation = this.validateModelConfig(config);
|
||||
|
||||
if (basicValidation) {
|
||||
selectedModel = config;
|
||||
}
|
||||
|
||||
if (!selectedModel) {
|
||||
// fetch default model
|
||||
const defaultModel = await this.fetchDefaultModel(projectId);
|
||||
const defaultConfig = {
|
||||
provider: defaultModel?.provider,
|
||||
model: defaultModel?.model,
|
||||
modelParams: defaultModel?.modelParams,
|
||||
};
|
||||
const basicValidation = this.validateModelConfig(defaultConfig);
|
||||
|
||||
if (basicValidation) {
|
||||
selectedModel = defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedModel) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `No default model or custom model found for project ${projectId}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if API key exists for this provider
|
||||
const apiKey = await prisma.llmApiKeys.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
provider: selectedModel.provider,
|
||||
},
|
||||
});
|
||||
|
||||
const parsedKey = LLMApiKeySchema.safeParse(apiKey);
|
||||
|
||||
if (!parsedKey.success) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `API key for provider "${selectedModel.provider}" not found in project ${projectId}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
config: {
|
||||
...selectedModel,
|
||||
apiKey: parsedKey.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DefaultEvalModelService";
|
||||
@@ -56,10 +56,10 @@ export class PromptService {
|
||||
if ((await this.shouldUseCache(params)) && dbPrompt) {
|
||||
await this.cachePrompt({ ...params, prompt: dbPrompt });
|
||||
|
||||
this.logInfo("Successfully cached prompt for params", params);
|
||||
this.logDebug("Successfully cached prompt for params", params);
|
||||
}
|
||||
|
||||
this.logInfo("Returning DB prompt for params", params);
|
||||
this.logDebug("Returning DB prompt for params", params);
|
||||
|
||||
return dbPrompt;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export class PromptService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolvePrompt(
|
||||
public async resolvePrompt(
|
||||
prompt: Prompt | null,
|
||||
): Promise<PromptResult | null> {
|
||||
if (!prompt) return prompt;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
PutObjectCommandInput,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
@@ -65,6 +66,8 @@ export class StorageServiceFactory {
|
||||
* @param params.useAzureBlob - Use Azure Blob Storage instead of S3
|
||||
* @param params.useGoogleCloudStorage - Use Google Cloud Storage instead of S3
|
||||
* @param params.googleCloudCredentials - Google Cloud Storage credentials JSON string or path to credentials file
|
||||
* @param params.awsSse - Server-side encryption method (e.g., "aws:kms")
|
||||
* @param params.awsSseKmsKeyId - SSE KMS Key ID when using KMS encryption
|
||||
*/
|
||||
public static getInstance(params: {
|
||||
accessKeyId: string | undefined;
|
||||
@@ -77,6 +80,8 @@ export class StorageServiceFactory {
|
||||
useAzureBlob?: boolean;
|
||||
useGoogleCloudStorage?: boolean;
|
||||
googleCloudCredentials?: string;
|
||||
awsSse: string | undefined;
|
||||
awsSseKmsKeyId: string | undefined;
|
||||
}): StorageService {
|
||||
if (params.useAzureBlob || env.LANGFUSE_USE_AZURE_BLOB === "true") {
|
||||
return new AzureBlobStorageService(params);
|
||||
@@ -362,9 +367,10 @@ class AzureBlobStorageService implements StorageService {
|
||||
|
||||
class S3StorageService implements StorageService {
|
||||
private client: S3Client;
|
||||
private signedUrlClient: S3Client;
|
||||
private bucketName: string;
|
||||
private endpoint: string | undefined;
|
||||
private externalEndpoint: string | undefined;
|
||||
private awsSse: string | undefined;
|
||||
private awsSseKmsKeyId: string | undefined;
|
||||
|
||||
constructor(params: {
|
||||
accessKeyId: string | undefined;
|
||||
@@ -374,6 +380,8 @@ class S3StorageService implements StorageService {
|
||||
externalEndpoint?: string | undefined;
|
||||
region: string | undefined;
|
||||
forcePathStyle: boolean;
|
||||
awsSse: string | undefined;
|
||||
awsSseKmsKeyId: string | undefined;
|
||||
}) {
|
||||
// Use accessKeyId and secretAccessKey if provided or fallback to default credentials
|
||||
const { accessKeyId, secretAccessKey } = params;
|
||||
@@ -385,6 +393,7 @@ class S3StorageService implements StorageService {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Create the main client for S3 operations using the internal endpoint
|
||||
this.client = new S3Client({
|
||||
credentials,
|
||||
endpoint: params.endpoint,
|
||||
@@ -396,9 +405,37 @@ class S3StorageService implements StorageService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a separate client for generating presigned URLs
|
||||
// If an external endpoint is provided, use it for the URL client
|
||||
// Otherwise, use the same client for both operations
|
||||
this.signedUrlClient = params.externalEndpoint
|
||||
? new S3Client({
|
||||
credentials,
|
||||
endpoint: params.externalEndpoint,
|
||||
region: params.region,
|
||||
forcePathStyle: params.forcePathStyle,
|
||||
requestHandler: {
|
||||
httpsAgent: {
|
||||
maxSockets: env.LANGFUSE_S3_CONCURRENT_WRITES,
|
||||
},
|
||||
},
|
||||
})
|
||||
: this.client;
|
||||
|
||||
this.bucketName = params.bucketName;
|
||||
this.endpoint = params.endpoint;
|
||||
this.externalEndpoint = params.externalEndpoint;
|
||||
this.awsSse = params.awsSse;
|
||||
this.awsSseKmsKeyId = params.awsSseKmsKeyId;
|
||||
}
|
||||
|
||||
private addSSEToParams<T>(params: Record<string, unknown>): T {
|
||||
if (this.awsSse) {
|
||||
params.ServerSideEncryption = this.awsSse;
|
||||
if (this.awsSse === "aws:kms" && this.awsSseKmsKeyId) {
|
||||
params.SSEKMSKeyId = this.awsSseKmsKeyId;
|
||||
}
|
||||
}
|
||||
return params as T;
|
||||
}
|
||||
|
||||
public async uploadFile({
|
||||
@@ -410,12 +447,12 @@ class S3StorageService implements StorageService {
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.client,
|
||||
params: {
|
||||
params: this.addSSEToParams<PutObjectCommandInput>({
|
||||
Bucket: this.bucketName,
|
||||
Key: fileName,
|
||||
Body: data,
|
||||
ContentType: fileType,
|
||||
},
|
||||
}),
|
||||
}).done();
|
||||
|
||||
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
|
||||
@@ -423,17 +460,19 @@ class S3StorageService implements StorageService {
|
||||
return { signedUrl };
|
||||
} catch (err) {
|
||||
logger.error(`Failed to upload file to ${fileName}`, err);
|
||||
throw new Error("Failed to upload to S3 or generate signed URL");
|
||||
throw new Error(`Failed to upload to S3 or generate signed URL: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadJson(path: string, body: Record<string, unknown>[]) {
|
||||
const putCommand = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: path,
|
||||
Body: JSON.stringify(body),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
const putCommand = new PutObjectCommand(
|
||||
this.addSSEToParams({
|
||||
Bucket: this.bucketName,
|
||||
Key: path,
|
||||
Body: JSON.stringify(body),
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await this.client.send(putCommand);
|
||||
@@ -487,8 +526,8 @@ class S3StorageService implements StorageService {
|
||||
asAttachment: boolean = true,
|
||||
): Promise<string> {
|
||||
try {
|
||||
let url = await getSignedUrl(
|
||||
this.client,
|
||||
return getSignedUrl(
|
||||
this.signedUrlClient,
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: fileName,
|
||||
@@ -498,17 +537,6 @@ class S3StorageService implements StorageService {
|
||||
}),
|
||||
{ expiresIn: ttlSeconds },
|
||||
);
|
||||
|
||||
// Replace internal endpoint with external endpoint if configured
|
||||
if (
|
||||
this.externalEndpoint &&
|
||||
this.endpoint &&
|
||||
url.includes(this.endpoint)
|
||||
) {
|
||||
url = url.replace(this.endpoint, this.externalEndpoint);
|
||||
}
|
||||
|
||||
return url;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to generate presigned URL for ${fileName}`, err);
|
||||
throw Error("Failed to generate signed URL");
|
||||
@@ -566,28 +594,23 @@ class S3StorageService implements StorageService {
|
||||
}): Promise<string> {
|
||||
const { path, ttlSeconds, contentType, contentLength, sha256Hash } = params;
|
||||
|
||||
let url = await getSignedUrl(
|
||||
this.client,
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: path,
|
||||
ContentType: contentType,
|
||||
ChecksumSHA256: sha256Hash,
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
return getSignedUrl(
|
||||
this.signedUrlClient,
|
||||
new PutObjectCommand(
|
||||
this.addSSEToParams({
|
||||
Bucket: this.bucketName,
|
||||
Key: path,
|
||||
ContentType: contentType,
|
||||
ChecksumSHA256: sha256Hash,
|
||||
ContentLength: contentLength,
|
||||
}),
|
||||
),
|
||||
{
|
||||
expiresIn: ttlSeconds,
|
||||
signableHeaders: new Set(["content-type", "content-length"]),
|
||||
unhoistableHeaders: new Set(["x-amz-checksum-sha256"]),
|
||||
},
|
||||
);
|
||||
|
||||
// Replace internal endpoint with external endpoint if configured
|
||||
if (this.externalEndpoint && this.endpoint && url.includes(this.endpoint)) {
|
||||
url = url.replace(this.endpoint, this.externalEndpoint);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-18
@@ -5,7 +5,6 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
@@ -21,11 +20,11 @@ import { render } from "@react-email/render";
|
||||
import { type SendVerificationRequestParams } from "next-auth/providers/email";
|
||||
|
||||
interface ResetPasswordTemplateProps {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
|
||||
const previewText = "Reset your Langfuse password";
|
||||
const ResetPasswordTemplate = ({ token }: ResetPasswordTemplateProps) => {
|
||||
const previewText = "Your Langfuse reset code";
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
@@ -48,17 +47,14 @@ const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
|
||||
It happens to the best of us.
|
||||
</Heading>
|
||||
<Section className="mb-8 mt-8 text-center">
|
||||
<Button
|
||||
className="rounded bg-black px-5 py-3 text-center text-xs font-semibold text-white no-underline"
|
||||
href={url}
|
||||
>
|
||||
Reset your password
|
||||
</Button>
|
||||
<Text className="text-center text-sm font-semibold">
|
||||
Your one time passcode:
|
||||
</Text>
|
||||
<Heading className="text-3xl mt-2">{token}</Heading>
|
||||
</Section>
|
||||
<Text className="text-center text-xs leading-6 text-[#666666]">
|
||||
The link is valid for 10 minutes. If you do not want to change
|
||||
your password or didn't request a reset, you can ignore and
|
||||
delete this email.
|
||||
This code is valid for 3 minutes. If you did not request a reset,
|
||||
you can ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
@@ -68,16 +64,16 @@ const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
|
||||
};
|
||||
|
||||
export async function sendResetPasswordVerificationRequest(
|
||||
params: SendVerificationRequestParams
|
||||
params: SendVerificationRequestParams,
|
||||
) {
|
||||
const { identifier, url, provider } = params;
|
||||
const { identifier, token, provider } = params as SendVerificationRequestParams & { token: string };
|
||||
const transport = createTransport(provider.server);
|
||||
const htmlTemplate = render(<ResetPasswordTemplate url={url} />);
|
||||
const htmlTemplate = render(<ResetPasswordTemplate token={token} />);
|
||||
const result = await transport.sendMail({
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Forgot your password?`,
|
||||
text: `To reset your Langfuse password, please confirm your email:\n${url}\n\nThe link is valid for 10 minutes. If you do not want to change your password or didn't request a reset, you can ignore and delete this email.`,
|
||||
subject: `Your Langfuse password reset code`,
|
||||
text: `Use the following code to reset your Langfuse password: ${token}\n\nThis code will expire in 3 minutes. If you did not request a reset, you can ignore this email.`,
|
||||
html: htmlTemplate,
|
||||
});
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
|
||||
@@ -157,7 +157,7 @@ export type TracesTableMetricsClickhouseReturnType = {
|
||||
};
|
||||
|
||||
export type FetchTracesTableProps = {
|
||||
select: "count" | "rows" | "metrics";
|
||||
select: "count" | "rows" | "metrics" | "identifiers";
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
@@ -169,84 +169,42 @@ export type FetchTracesTableProps = {
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const getTracesTableCount = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
searchType: TracingSearchType[];
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const countRows = await getTracesTableGeneric<{ count: string }>({
|
||||
select: "count",
|
||||
tags: { kind: "count" },
|
||||
...props,
|
||||
});
|
||||
|
||||
const converted = countRows.map((row) => ({
|
||||
count: Number(row.count),
|
||||
}));
|
||||
|
||||
return converted.length > 0 ? converted[0].count : 0;
|
||||
// Define return type mapping for better type safety
|
||||
type SelectReturnTypeMap = {
|
||||
count: { count: string };
|
||||
metrics: TracesTableMetricsClickhouseReturnType;
|
||||
rows: TracesTableReturnType;
|
||||
identifiers: { id: string; projectId: string; timestamp: string };
|
||||
};
|
||||
|
||||
export const getTracesTableMetrics = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
|
||||
}): Promise<Array<Omit<TracesMetricsUiReturnType, "scores">>> => {
|
||||
const countRows =
|
||||
await getTracesTableGeneric<TracesTableMetricsClickhouseReturnType>({
|
||||
select: "metrics",
|
||||
tags: { kind: "analytic" },
|
||||
...props,
|
||||
});
|
||||
// Function overloads for type-safe select-specific returns
|
||||
async function getTracesTableGeneric(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
props: FetchTracesTableProps & { select: "count" },
|
||||
): Promise<Array<SelectReturnTypeMap["count"]>>;
|
||||
|
||||
return countRows.map(convertToUITableMetrics);
|
||||
};
|
||||
async function getTracesTableGeneric(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
props: FetchTracesTableProps & { select: "metrics" },
|
||||
): Promise<Array<SelectReturnTypeMap["metrics"]>>;
|
||||
|
||||
export const getTracesTable = async (p: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
searchType?: TracingSearchType[];
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
|
||||
}) => {
|
||||
const {
|
||||
projectId,
|
||||
filter,
|
||||
searchQuery,
|
||||
searchType,
|
||||
orderBy,
|
||||
limit,
|
||||
page,
|
||||
clickhouseConfigs,
|
||||
} = p;
|
||||
const rows = await getTracesTableGeneric<TracesTableReturnType>({
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
projectId,
|
||||
filter,
|
||||
searchQuery,
|
||||
searchType,
|
||||
orderBy,
|
||||
limit,
|
||||
page,
|
||||
clickhouseConfigs,
|
||||
});
|
||||
async function getTracesTableGeneric(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
props: FetchTracesTableProps & { select: "rows" },
|
||||
): Promise<Array<SelectReturnTypeMap["rows"]>>;
|
||||
|
||||
return rows.map(convertToUiTableRows);
|
||||
};
|
||||
async function getTracesTableGeneric(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
props: FetchTracesTableProps & { select: "identifiers" },
|
||||
): Promise<Array<SelectReturnTypeMap["identifiers"]>>;
|
||||
|
||||
const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
// Implementation with union type for internal use
|
||||
async function getTracesTableGeneric(
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
props: FetchTracesTableProps,
|
||||
): Promise<Array<SelectReturnTypeMap[keyof SelectReturnTypeMap]>>;
|
||||
|
||||
async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
const {
|
||||
select,
|
||||
projectId,
|
||||
@@ -297,6 +255,12 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
t.session_id as session_id,
|
||||
t.public as public`;
|
||||
break;
|
||||
case "identifiers":
|
||||
sqlSelect = `
|
||||
t.id as id,
|
||||
t.project_id as projectId,
|
||||
t.timestamp as timestamp`;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown select type: ${select}`);
|
||||
}
|
||||
@@ -471,7 +435,7 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
)
|
||||
SELECT ${sqlSelect}
|
||||
-- FINAL is used for non default ordering and count.
|
||||
FROM traces t ${["metrics", "rows"].includes(select) && defaultOrder ? "" : "FINAL"}
|
||||
FROM traces t ${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "" : "FINAL"}
|
||||
${select === "metrics" || requiresObservationsJoin ? `LEFT JOIN observations_stats os on os.project_id = t.project_id and os.trace_id = t.id` : ""}
|
||||
${select === "metrics" || requiresScoresJoin ? `LEFT JOIN scores_avg s on s.project_id = t.project_id and s.trace_id = t.id` : ""}
|
||||
WHERE t.project_id = {projectId: String}
|
||||
@@ -480,11 +444,13 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
${chOrderBy}
|
||||
-- This is used for metrics and row queries. Count has only one result.
|
||||
-- This is only used for default ordering. Otherwise, we use final.
|
||||
${["metrics", "rows"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
|
||||
${["metrics", "rows", "identifiers"].includes(select) && defaultOrder ? "LIMIT 1 BY id, project_id" : ""}
|
||||
${limit !== undefined && page !== undefined ? `LIMIT {limit: Int32} OFFSET {offset: Int32}` : ""}
|
||||
`;
|
||||
|
||||
const res = await queryClickhouse<T>({
|
||||
const res = await queryClickhouse<
|
||||
SelectReturnTypeMap[keyof SelectReturnTypeMap]
|
||||
>({
|
||||
query: query,
|
||||
params: {
|
||||
limit: limit,
|
||||
@@ -506,4 +472,120 @@ const getTracesTableGeneric = async <T>(props: FetchTracesTableProps) => {
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export const getTracesTableCount = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
searchType: TracingSearchType[];
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}) => {
|
||||
const countRows = await getTracesTableGeneric({
|
||||
select: "count",
|
||||
tags: { kind: "count" },
|
||||
...props,
|
||||
});
|
||||
|
||||
const converted = countRows.map((row) => ({
|
||||
count: Number(row.count),
|
||||
}));
|
||||
|
||||
return converted.length > 0 ? converted[0].count : 0;
|
||||
};
|
||||
|
||||
export const getTracesTableMetrics = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
|
||||
}): Promise<Array<Omit<TracesMetricsUiReturnType, "scores">>> => {
|
||||
const countRows = await getTracesTableGeneric({
|
||||
select: "metrics",
|
||||
tags: { kind: "analytic" },
|
||||
...props,
|
||||
});
|
||||
|
||||
return countRows.map(convertToUITableMetrics);
|
||||
};
|
||||
|
||||
export const getTracesTable = async (p: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
searchType?: TracingSearchType[];
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
|
||||
}) => {
|
||||
const {
|
||||
projectId,
|
||||
filter,
|
||||
searchQuery,
|
||||
searchType,
|
||||
orderBy,
|
||||
limit,
|
||||
page,
|
||||
clickhouseConfigs,
|
||||
} = p;
|
||||
const rows = await getTracesTableGeneric({
|
||||
select: "rows",
|
||||
tags: { kind: "list" },
|
||||
projectId,
|
||||
filter,
|
||||
searchQuery,
|
||||
searchType,
|
||||
orderBy,
|
||||
limit,
|
||||
page,
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return rows.map(convertToUiTableRows);
|
||||
};
|
||||
|
||||
export const getTraceIdentifiers = async (props: {
|
||||
projectId: string;
|
||||
filter: FilterState;
|
||||
searchQuery?: string;
|
||||
searchType?: TracingSearchType[];
|
||||
orderBy?: OrderByState;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
clickhouseConfigs?: ClickHouseClientConfigOptions | undefined;
|
||||
}) => {
|
||||
const {
|
||||
projectId,
|
||||
filter,
|
||||
searchQuery,
|
||||
searchType,
|
||||
orderBy,
|
||||
limit,
|
||||
page,
|
||||
clickhouseConfigs,
|
||||
} = props;
|
||||
const identifiers = await getTracesTableGeneric({
|
||||
select: "identifiers",
|
||||
tags: { kind: "list" },
|
||||
projectId,
|
||||
filter,
|
||||
searchQuery,
|
||||
searchType,
|
||||
orderBy,
|
||||
limit,
|
||||
page,
|
||||
clickhouseConfigs,
|
||||
});
|
||||
|
||||
return identifiers.map((row) => ({
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
timestamp: parseClickhouseUTCDateTimeFormat(row.timestamp),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -74,6 +74,8 @@ export const tableNames = [
|
||||
"sessions",
|
||||
"prompts",
|
||||
"users",
|
||||
"job_configurations",
|
||||
"job_executions",
|
||||
"dataset_items",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -34,4 +34,6 @@ export type TableName =
|
||||
| "prompts"
|
||||
| "dashboard"
|
||||
| "widgets"
|
||||
| "users";
|
||||
| "users"
|
||||
| "eval_configs"
|
||||
| "job_executions";
|
||||
|
||||
Generated
+394
-334
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -85,7 +85,7 @@ ENV NEXT_MANUAL_SIG_HANDLE true
|
||||
# set the CI flag to true to get CI specific logs
|
||||
ENV CI true
|
||||
|
||||
RUN NODE_OPTIONS='--max-old-space-size=4096' turbo run build --filter=web...
|
||||
RUN NODE_OPTIONS='--max-old-space-size=8192' turbo run build --filter=web...
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} base AS runner
|
||||
@@ -149,6 +149,8 @@ USER nextjs
|
||||
# Default port to 3000
|
||||
ENV PORT 3000
|
||||
|
||||
# Docker ENTRYPOINT (dumb-init) is covered by semantic versioning, not the entrypoint.sh itself
|
||||
# Reasoning: ENTRYPOINT is overridden by some self-hosted deployments, thus changing this is breaking
|
||||
ENTRYPOINT ["dumb-init", "--", "./web/entrypoint.sh"]
|
||||
|
||||
# startup command - use dd-trace if NEXT_PUBLIC_LANGFUSE_CLOUD_REGION is configured
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.56.0",
|
||||
"version": "3.66.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -136,6 +136,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-grid-layout": "^1.5.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
@@ -159,7 +160,7 @@
|
||||
"uuid": "^9.0.1",
|
||||
"vaul": "^1.1.2",
|
||||
"vis-network": "^9.1.9",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jedmao/location": "^3.0.0",
|
||||
@@ -178,6 +179,7 @@
|
||||
"@types/node": "20.10.5",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-dom": "~18.2.25",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -845,6 +845,71 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateDatasetRunItemRequest'
|
||||
get:
|
||||
description: List dataset run items
|
||||
operationId: datasetRunItems_list
|
||||
tags:
|
||||
- DatasetRunItems
|
||||
parameters:
|
||||
- name: datasetId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: runName
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: page number, starts at 1
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: limit
|
||||
in: query
|
||||
description: limit of items per page
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: response
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedDatasetRunItems'
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/v2/datasets:
|
||||
get:
|
||||
description: Get all datasets
|
||||
@@ -1499,281 +1564,67 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetMediaUploadUrlRequest'
|
||||
/api/public/organizations/memberships:
|
||||
/api/public/metrics:
|
||||
get:
|
||||
description: >-
|
||||
Get all memberships for the organization associated with the API key
|
||||
(requires organization-scoped API key)
|
||||
operationId: memberships_getOrganizationMemberships
|
||||
tags:
|
||||
- Memberships
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipsResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
put:
|
||||
description: >-
|
||||
Create or update a membership for the organization associated with the
|
||||
API key (requires organization-scoped API key)
|
||||
operationId: memberships_updateOrganizationMembership
|
||||
tags:
|
||||
- Memberships
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipRequest'
|
||||
/api/public/projects/{projectId}/memberships:
|
||||
get:
|
||||
description: >-
|
||||
Get all memberships for a specific project (requires organization-scoped
|
||||
API key)
|
||||
operationId: memberships_getProjectMemberships
|
||||
tags:
|
||||
- Memberships
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipsResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
put:
|
||||
description: >-
|
||||
Create or update a membership for a specific project (requires
|
||||
organization-scoped API key). The user must already be a member of the
|
||||
organization.
|
||||
operationId: memberships_updateProjectMembership
|
||||
tags:
|
||||
- Memberships
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipRequest'
|
||||
/api/public/metrics/daily:
|
||||
get:
|
||||
description: Get daily metrics of the Langfuse project
|
||||
operationId: metrics_daily
|
||||
description: Get metrics from the Langfuse project using a query object
|
||||
operationId: metrics_metrics
|
||||
tags:
|
||||
- Metrics
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: page number, starts at 1
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: limit
|
||||
in: query
|
||||
description: limit of items per page
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
nullable: true
|
||||
- name: traceName
|
||||
in: query
|
||||
description: Optional filter by the name of the trace
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: userId
|
||||
in: query
|
||||
description: Optional filter by the userId associated with the trace
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: tags
|
||||
in: query
|
||||
description: Optional filter for metrics where traces include all of these tags
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: environment
|
||||
- name: query
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter for metrics where events include any of these
|
||||
environments
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
- name: fromTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include traces and observations on or after
|
||||
a certain datetime (ISO 8601)
|
||||
required: false
|
||||
JSON string containing the query parameters with the following
|
||||
structure:
|
||||
|
||||
```json
|
||||
|
||||
{
|
||||
"view": string, // Required. One of "traces", "observations", "scores-numeric", "scores-categorical"
|
||||
"dimensions": [ // Optional. Default: []
|
||||
{
|
||||
"field": string // Field to group by, e.g. "name", "userId", "sessionId"
|
||||
}
|
||||
],
|
||||
"metrics": [ // Required. At least one metric must be provided
|
||||
{
|
||||
"measure": string, // What to measure, e.g. "count", "latency", "value"
|
||||
"aggregation": string // How to aggregate, e.g. "count", "sum", "avg", "p95"
|
||||
}
|
||||
],
|
||||
"filters": [ // Optional. Default: []
|
||||
{
|
||||
"column": string, // Column to filter on
|
||||
"operator": string, // Operator, e.g. "=", ">", "<", "contains"
|
||||
"value": any, // Value to compare against
|
||||
"type": string, // Data type, e.g. "string", "number", "stringObject"
|
||||
"key": string // Required only when filtering on metadata
|
||||
}
|
||||
],
|
||||
"timeDimension": { // Optional. Default: null. If provided, results will be grouped by time
|
||||
"granularity": string // One of "minute", "hour", "day", "week", "month", "auto"
|
||||
},
|
||||
"fromTimestamp": string, // Required. ISO datetime string for start of time range
|
||||
"toTimestamp": string, // Required. ISO datetime string for end of time range
|
||||
"orderBy": [ // Optional. Default: null
|
||||
{
|
||||
"field": string, // Field to order by
|
||||
"direction": string // "asc" or "desc"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
- name: toTimestamp
|
||||
in: query
|
||||
description: >-
|
||||
Optional filter to only include traces and observations before a
|
||||
certain datetime (ISO 8601)
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DailyMetrics'
|
||||
$ref: '#/components/schemas/MetricsResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
@@ -2108,7 +1959,7 @@ paths:
|
||||
- name: fromStartTime
|
||||
in: query
|
||||
description: >-
|
||||
Retrieve only observations with a start_time or or after this
|
||||
Retrieve only observations with a start_time on or after this
|
||||
datetime (ISO 8601).
|
||||
required: false
|
||||
schema:
|
||||
@@ -2166,6 +2017,242 @@ paths:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/organizations/memberships:
|
||||
get:
|
||||
description: >-
|
||||
Get all memberships for the organization associated with the API key
|
||||
(requires organization-scoped API key)
|
||||
operationId: organizations_getOrganizationMemberships
|
||||
tags:
|
||||
- Organizations
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipsResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
put:
|
||||
description: >-
|
||||
Create or update a membership for the organization associated with the
|
||||
API key (requires organization-scoped API key)
|
||||
operationId: organizations_updateOrganizationMembership
|
||||
tags:
|
||||
- Organizations
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipRequest'
|
||||
/api/public/projects/{projectId}/memberships:
|
||||
get:
|
||||
description: >-
|
||||
Get all memberships for a specific project (requires organization-scoped
|
||||
API key)
|
||||
operationId: organizations_getProjectMemberships
|
||||
tags:
|
||||
- Organizations
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipsResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
put:
|
||||
description: >-
|
||||
Create or update a membership for a specific project (requires
|
||||
organization-scoped API key). The user must already be a member of the
|
||||
organization.
|
||||
operationId: organizations_updateProjectMembership
|
||||
tags:
|
||||
- Organizations
|
||||
parameters:
|
||||
- name: projectId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MembershipRequest'
|
||||
/api/public/organizations/projects:
|
||||
get:
|
||||
description: >-
|
||||
Get all projects for the organization associated with the API key
|
||||
(requires organization-scoped API key)
|
||||
operationId: organizations_getOrganizationProjects
|
||||
tags:
|
||||
- Organizations
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrganizationProjectsResponse'
|
||||
'400':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'401':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'403':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'404':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'405':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- BasicAuth: []
|
||||
/api/public/projects:
|
||||
get:
|
||||
description: Get Project associated with API key
|
||||
@@ -2264,7 +2351,7 @@ paths:
|
||||
retention:
|
||||
type: integer
|
||||
description: >-
|
||||
Number of days to retain data. Must be 0 or at least 7 days.
|
||||
Number of days to retain data. Must be 0 or at least 3 days.
|
||||
Requires data-retention entitlement for non-zero values.
|
||||
Optional.
|
||||
required:
|
||||
@@ -2333,7 +2420,7 @@ paths:
|
||||
retention:
|
||||
type: integer
|
||||
description: >-
|
||||
Number of days to retain data. Must be 0 or at least 7 days.
|
||||
Number of days to retain data. Must be 0 or at least 3 days.
|
||||
Requires data-retention entitlement for non-zero values.
|
||||
Optional.
|
||||
required:
|
||||
@@ -5181,19 +5268,19 @@ components:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: Price (USD) per input unit
|
||||
description: Deprecated. See 'prices' instead. Price (USD) per input unit
|
||||
outputPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: Price (USD) per output unit
|
||||
description: Deprecated. See 'prices' instead. Price (USD) per output unit
|
||||
totalPrice:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: >-
|
||||
Price (USD) per total unit. Cannot be set if input or output price
|
||||
is set.
|
||||
Deprecated. See 'prices' instead. Price (USD) per total unit. Cannot
|
||||
be set if input or output price is set.
|
||||
tokenizerId:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -5207,11 +5294,26 @@ components:
|
||||
JSON. See docs for more details.
|
||||
isLangfuseManaged:
|
||||
type: boolean
|
||||
prices:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/ModelPrice'
|
||||
description: Price (USD) by usage type
|
||||
required:
|
||||
- id
|
||||
- modelName
|
||||
- matchPattern
|
||||
- isLangfuseManaged
|
||||
- prices
|
||||
ModelPrice:
|
||||
title: ModelPrice
|
||||
type: object
|
||||
properties:
|
||||
price:
|
||||
type: number
|
||||
format: double
|
||||
required:
|
||||
- price
|
||||
ModelUsageUnit:
|
||||
title: ModelUsageUnit
|
||||
type: string
|
||||
@@ -5351,6 +5453,19 @@ components:
|
||||
required:
|
||||
- runName
|
||||
- datasetItemId
|
||||
PaginatedDatasetRunItems:
|
||||
title: PaginatedDatasetRunItems
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DatasetRunItem'
|
||||
meta:
|
||||
$ref: '#/components/schemas/utilsMetaResponse'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
PaginatedDatasets:
|
||||
title: PaginatedDatasets
|
||||
type: object
|
||||
@@ -6220,125 +6335,22 @@ components:
|
||||
- application/xml
|
||||
- application/octet-stream
|
||||
description: The MIME type of the media record
|
||||
MembershipRole:
|
||||
title: MembershipRole
|
||||
type: string
|
||||
enum:
|
||||
- OWNER
|
||||
- ADMIN
|
||||
- MEMBER
|
||||
- VIEWER
|
||||
MembershipRequest:
|
||||
title: MembershipRequest
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/MembershipRole'
|
||||
required:
|
||||
- userId
|
||||
- role
|
||||
MembershipResponse:
|
||||
title: MembershipResponse
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/MembershipRole'
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- userId
|
||||
- role
|
||||
- email
|
||||
- name
|
||||
MembershipsResponse:
|
||||
title: MembershipsResponse
|
||||
type: object
|
||||
properties:
|
||||
memberships:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MembershipResponse'
|
||||
required:
|
||||
- memberships
|
||||
DailyMetrics:
|
||||
title: DailyMetrics
|
||||
MetricsResponse:
|
||||
title: MetricsResponse
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DailyMetricsDetails'
|
||||
description: A list of daily metrics, only days with ingested data are included.
|
||||
meta:
|
||||
$ref: '#/components/schemas/utilsMetaResponse'
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: >-
|
||||
The metrics data. Each item in the list contains the metric values
|
||||
and dimensions requested in the query.
|
||||
|
||||
Format varies based on the query parameters.
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
DailyMetricsDetails:
|
||||
title: DailyMetricsDetails
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
countTraces:
|
||||
type: integer
|
||||
countObservations:
|
||||
type: integer
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
description: Total model cost in USD
|
||||
usage:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UsageByModel'
|
||||
required:
|
||||
- date
|
||||
- countTraces
|
||||
- countObservations
|
||||
- totalCost
|
||||
- usage
|
||||
UsageByModel:
|
||||
title: UsageByModel
|
||||
type: object
|
||||
description: >-
|
||||
Daily usage of a given model. Usage corresponds to the unit set for the
|
||||
specific model (e.g. tokens).
|
||||
properties:
|
||||
model:
|
||||
type: string
|
||||
nullable: true
|
||||
inputUsage:
|
||||
type: integer
|
||||
description: Total number of generation input units (e.g. tokens)
|
||||
outputUsage:
|
||||
type: integer
|
||||
description: Total number of generation output units (e.g. tokens)
|
||||
totalUsage:
|
||||
type: integer
|
||||
description: Total number of generation total units (e.g. tokens)
|
||||
countTraces:
|
||||
type: integer
|
||||
countObservations:
|
||||
type: integer
|
||||
totalCost:
|
||||
type: number
|
||||
format: double
|
||||
description: Total model cost in USD
|
||||
required:
|
||||
- inputUsage
|
||||
- outputUsage
|
||||
- totalUsage
|
||||
- countTraces
|
||||
- countObservations
|
||||
- totalCost
|
||||
PaginatedModels:
|
||||
title: PaginatedModels
|
||||
type: object
|
||||
@@ -6435,6 +6447,85 @@ components:
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
MembershipRole:
|
||||
title: MembershipRole
|
||||
type: string
|
||||
enum:
|
||||
- OWNER
|
||||
- ADMIN
|
||||
- MEMBER
|
||||
- VIEWER
|
||||
MembershipRequest:
|
||||
title: MembershipRequest
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/MembershipRole'
|
||||
required:
|
||||
- userId
|
||||
- role
|
||||
MembershipResponse:
|
||||
title: MembershipResponse
|
||||
type: object
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
role:
|
||||
$ref: '#/components/schemas/MembershipRole'
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- userId
|
||||
- role
|
||||
- email
|
||||
- name
|
||||
MembershipsResponse:
|
||||
title: MembershipsResponse
|
||||
type: object
|
||||
properties:
|
||||
memberships:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MembershipResponse'
|
||||
required:
|
||||
- memberships
|
||||
OrganizationProject:
|
||||
title: OrganizationProject
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
nullable: true
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- createdAt
|
||||
- updatedAt
|
||||
OrganizationProjectsResponse:
|
||||
title: OrganizationProjectsResponse
|
||||
type: object
|
||||
properties:
|
||||
projects:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OrganizationProject'
|
||||
required:
|
||||
- projects
|
||||
Projects:
|
||||
title: Projects
|
||||
type: object
|
||||
@@ -6459,6 +6550,7 @@ components:
|
||||
description: Metadata for the project
|
||||
retentionDays:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: >-
|
||||
Number of days to retain data. Null or 0 means no retention. Omitted
|
||||
if no retention is configured.
|
||||
@@ -6466,7 +6558,6 @@ components:
|
||||
- id
|
||||
- name
|
||||
- metadata
|
||||
- retentionDays
|
||||
ProjectDeletionResponse:
|
||||
title: ProjectDeletionResponse
|
||||
type: object
|
||||
|
||||
@@ -642,6 +642,57 @@
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "List",
|
||||
"request": {
|
||||
"description": "List dataset run items",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/dataset-run-items?datasetId=&runName=&page=&limit=&response=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"dataset-run-items"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "datasetId",
|
||||
"value": "",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"key": "runName",
|
||||
"value": "",
|
||||
"description": null
|
||||
},
|
||||
{
|
||||
"key": "page",
|
||||
"value": "",
|
||||
"description": "page number, starts at 1"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "",
|
||||
"description": "limit of items per page"
|
||||
},
|
||||
{
|
||||
"key": "response",
|
||||
"value": "",
|
||||
"description": null
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1195,147 +1246,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
"name": "Memberships",
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get Organization Memberships",
|
||||
"request": {
|
||||
"description": "Get all memberships for the organization associated with the API key (requires organization-scoped API key)",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/organizations/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"organizations",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Update Organization Membership",
|
||||
"request": {
|
||||
"description": "Create or update a membership for the organization associated with the API key (requires organization-scoped API key)",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/organizations/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"organizations",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "PUT",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get Project Memberships",
|
||||
"request": {
|
||||
"description": "Get all memberships for a specific project (requires organization-scoped API key)",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"projects",
|
||||
":projectId",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"value": "",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Update Project Membership",
|
||||
"request": {
|
||||
"description": "Create or update a membership for a specific project (requires organization-scoped API key). The user must already be a member of the organization.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"projects",
|
||||
":projectId",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"value": "",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "PUT",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
@@ -1343,60 +1253,24 @@
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Daily",
|
||||
"name": "Metrics",
|
||||
"request": {
|
||||
"description": "Get daily metrics of the Langfuse project",
|
||||
"description": "Get metrics from the Langfuse project using a query object",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/metrics/daily?page=&limit=&traceName=&userId=&tags=&environment=&fromTimestamp=&toTimestamp=",
|
||||
"raw": "{{baseUrl}}/api/public/metrics?query=",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"metrics",
|
||||
"daily"
|
||||
"metrics"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "page",
|
||||
"key": "query",
|
||||
"value": "",
|
||||
"description": "page number, starts at 1"
|
||||
},
|
||||
{
|
||||
"key": "limit",
|
||||
"value": "",
|
||||
"description": "limit of items per page"
|
||||
},
|
||||
{
|
||||
"key": "traceName",
|
||||
"value": "",
|
||||
"description": "Optional filter by the name of the trace"
|
||||
},
|
||||
{
|
||||
"key": "userId",
|
||||
"value": "",
|
||||
"description": "Optional filter by the userId associated with the trace"
|
||||
},
|
||||
{
|
||||
"key": "tags",
|
||||
"value": "",
|
||||
"description": "Optional filter for metrics where traces include all of these tags"
|
||||
},
|
||||
{
|
||||
"key": "environment",
|
||||
"value": "",
|
||||
"description": "Optional filter for metrics where events include any of these environments"
|
||||
},
|
||||
{
|
||||
"key": "fromTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces and observations on or after a certain datetime (ISO 8601)"
|
||||
},
|
||||
{
|
||||
"key": "toTimestamp",
|
||||
"value": "",
|
||||
"description": "Optional filter to only include traces and observations before a certain datetime (ISO 8601)"
|
||||
"description": "JSON string containing the query parameters with the following structure:\n```json\n{\n \"view\": string, // Required. One of \"traces\", \"observations\", \"scores-numeric\", \"scores-categorical\"\n \"dimensions\": [ // Optional. Default: []\n {\n \"field\": string // Field to group by, e.g. \"name\", \"userId\", \"sessionId\"\n }\n ],\n \"metrics\": [ // Required. At least one metric must be provided\n {\n \"measure\": string, // What to measure, e.g. \"count\", \"latency\", \"value\"\n \"aggregation\": string // How to aggregate, e.g. \"count\", \"sum\", \"avg\", \"p95\"\n }\n ],\n \"filters\": [ // Optional. Default: []\n {\n \"column\": string, // Column to filter on\n \"operator\": string, // Operator, e.g. \"=\", \">\", \"<\", \"contains\"\n \"value\": any, // Value to compare against\n \"type\": string, // Data type, e.g. \"string\", \"number\", \"stringObject\"\n \"key\": string // Required only when filtering on metadata\n }\n ],\n \"timeDimension\": { // Optional. Default: null. If provided, results will be grouped by time\n \"granularity\": string // One of \"minute\", \"hour\", \"day\", \"week\", \"month\", \"auto\"\n },\n \"fromTimestamp\": string, // Required. ISO datetime string for start of time range\n \"toTimestamp\": string, // Required. ISO datetime string for end of time range\n \"orderBy\": [ // Optional. Default: null\n {\n \"field\": string, // Field to order by\n \"direction\": string // \"asc\" or \"desc\"\n }\n ]\n}\n```"
|
||||
}
|
||||
],
|
||||
"variable": []
|
||||
@@ -1646,7 +1520,7 @@
|
||||
{
|
||||
"key": "fromStartTime",
|
||||
"value": "",
|
||||
"description": "Retrieve only observations with a start_time or or after this datetime (ISO 8601)."
|
||||
"description": "Retrieve only observations with a start_time on or after this datetime (ISO 8601)."
|
||||
},
|
||||
{
|
||||
"key": "toStartTime",
|
||||
@@ -1670,6 +1544,173 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
"name": "Organizations",
|
||||
"item": [
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get Organization Memberships",
|
||||
"request": {
|
||||
"description": "Get all memberships for the organization associated with the API key (requires organization-scoped API key)",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/organizations/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"organizations",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Update Organization Membership",
|
||||
"request": {
|
||||
"description": "Create or update a membership for the organization associated with the API key (requires organization-scoped API key)",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/organizations/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"organizations",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "PUT",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get Project Memberships",
|
||||
"request": {
|
||||
"description": "Get all memberships for a specific project (requires organization-scoped API key)",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"projects",
|
||||
":projectId",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"value": "",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Update Project Membership",
|
||||
"request": {
|
||||
"description": "Create or update a membership for a specific project (requires organization-scoped API key). The user must already be a member of the organization.",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/projects/:projectId/memberships",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"projects",
|
||||
":projectId",
|
||||
"memberships"
|
||||
],
|
||||
"query": [],
|
||||
"variable": [
|
||||
{
|
||||
"key": "projectId",
|
||||
"value": "",
|
||||
"description": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": [],
|
||||
"method": "PUT",
|
||||
"auth": null,
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"userId\": \"example\",\n \"role\": \"OWNER\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"_type": "endpoint",
|
||||
"name": "Get Organization Projects",
|
||||
"request": {
|
||||
"description": "Get all projects for the organization associated with the API key (requires organization-scoped API key)",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/public/organizations/projects",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"public",
|
||||
"organizations",
|
||||
"projects"
|
||||
],
|
||||
"query": [],
|
||||
"variable": []
|
||||
},
|
||||
"header": [],
|
||||
"method": "GET",
|
||||
"auth": null,
|
||||
"body": null
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "container",
|
||||
"description": null,
|
||||
|
||||
@@ -115,7 +115,10 @@ describe("Ingestion Pipeline", () => {
|
||||
expect(trace).not.toBeNull();
|
||||
expect(trace?.name).toBe("test trace");
|
||||
|
||||
const observation = await getObservationById(spanId, projectId);
|
||||
const observation = await getObservationById({
|
||||
id: spanId,
|
||||
projectId,
|
||||
});
|
||||
expect(observation).not.toBeNull();
|
||||
expect(observation?.name).toBe("test span");
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PostDatasetsV2Response,
|
||||
DeleteDatasetItemV1Response,
|
||||
DeleteDatasetRunV1Response,
|
||||
GetDatasetRunItemsV1Response,
|
||||
} from "@/src/features/public-api/types/datasets";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
@@ -1368,4 +1369,117 @@ describe("/api/public/datasets and /api/public/dataset-items API Endpoints", ()
|
||||
});
|
||||
expect(dbRunItemAfterDelete).toBeNull();
|
||||
});
|
||||
|
||||
it("should properly paginate and filter dataset run items", async () => {
|
||||
// Create a dataset
|
||||
const datasetName = `pagination-test-${v4()}`;
|
||||
const dataset = await makeZodVerifiedAPICall(
|
||||
PostDatasetsV1Response,
|
||||
"POST",
|
||||
"/api/public/datasets",
|
||||
{
|
||||
name: datasetName,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
// Create 10 dataset items with different inputs
|
||||
const itemIds = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const itemId = `item-${i}-${v4()}`;
|
||||
itemIds.push(itemId);
|
||||
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-items",
|
||||
{
|
||||
datasetName: datasetName,
|
||||
id: itemId,
|
||||
input: { value: `test-value-${i}` },
|
||||
metadata: { index: i },
|
||||
// Add sourceObservationId to odd numbered items
|
||||
sourceTraceId: v4(),
|
||||
sourceObservationId: i % 2 === 1 ? v4() : undefined,
|
||||
datasetId: dataset.body.id,
|
||||
},
|
||||
auth,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a run
|
||||
const runName = `run-${v4()}`;
|
||||
await prisma.datasetRuns.create({
|
||||
data: {
|
||||
id: v4(),
|
||||
datasetId: dataset.body.id,
|
||||
name: runName,
|
||||
metadata: {},
|
||||
projectId: dataset.body.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create 10 run items
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await makeZodVerifiedAPICall(
|
||||
PostDatasetRunItemsV1Response,
|
||||
"POST",
|
||||
"/api/public/dataset-run-items",
|
||||
{
|
||||
runName,
|
||||
datasetItemId: itemIds[i],
|
||||
traceId: v4(),
|
||||
metadata: { index: i },
|
||||
},
|
||||
auth,
|
||||
);
|
||||
}
|
||||
|
||||
// Test basic pagination with limit
|
||||
const pageSize = 3;
|
||||
const page1 = await makeZodVerifiedAPICall(
|
||||
GetDatasetRunItemsV1Response,
|
||||
"GET",
|
||||
`/api/public/dataset-run-items?datasetId=${dataset.body.id}&runName=${encodeURIComponent(runName)}&limit=${pageSize}&page=1`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(page1.status).toBe(200);
|
||||
expect(page1.body.data.length).toBe(pageSize);
|
||||
expect(page1.body.meta).toMatchObject({
|
||||
totalItems: 10,
|
||||
limit: pageSize,
|
||||
page: 1,
|
||||
totalPages: Math.ceil(10 / pageSize),
|
||||
});
|
||||
|
||||
// Test second page
|
||||
const page2 = await makeZodVerifiedAPICall(
|
||||
GetDatasetRunItemsV1Response,
|
||||
"GET",
|
||||
`/api/public/dataset-run-items?datasetId=${dataset.body.id}&runName=${encodeURIComponent(runName)}&limit=${pageSize}&page=2`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(page2.status).toBe(200);
|
||||
expect(page2.body.data.length).toBe(pageSize);
|
||||
// Check that we got different items on different pages
|
||||
const page1Ids = page1.body.data.map((item) => item.id);
|
||||
const page2Ids = page2.body.data.map((item) => item.id);
|
||||
page2Ids.forEach((id) => {
|
||||
expect(page1Ids).not.toContain(id);
|
||||
});
|
||||
|
||||
// Test non-existent dataset name
|
||||
const nonExistent = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/dataset-run-items?datasetId=${v4()}&runName=does-not-exist`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(nonExistent.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,11 @@ describe("evals trpc", () => {
|
||||
|
||||
const response = await caller.evals.allConfigs({
|
||||
projectId: project.id,
|
||||
filter: [],
|
||||
orderBy: {
|
||||
column: "createdAt",
|
||||
order: "DESC",
|
||||
},
|
||||
limit: 10,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
@@ -94,7 +94,10 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById({ traceId: entity.body.id, projectId });
|
||||
const trace = await getTraceById({
|
||||
traceId: entity.body.id,
|
||||
projectId,
|
||||
});
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace!.id).toBe(entity.body.id);
|
||||
expect(trace!.projectId).toBe(projectId);
|
||||
@@ -212,11 +215,11 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const observation = await getObservationById(
|
||||
entity.body.id,
|
||||
const observation = await getObservationById({
|
||||
id: entity.body.id,
|
||||
projectId,
|
||||
true,
|
||||
);
|
||||
fetchWithInputOutput: true,
|
||||
});
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation!.id).toBe(entity.body.id);
|
||||
expect(observation!.projectId).toBe(projectId);
|
||||
@@ -333,7 +336,10 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const trace = await getTraceById({ traceId: `${traceId}-${char}-test`, projectId });
|
||||
const trace = await getTraceById({
|
||||
traceId: `${traceId}-${char}-test`,
|
||||
projectId,
|
||||
});
|
||||
expect(trace).toBeDefined();
|
||||
expect(trace!.id).toBe(`${traceId}-${char}-test`);
|
||||
expect(trace!.projectId).toBe(projectId);
|
||||
@@ -569,11 +575,11 @@ describe("/api/public/ingestion API Endpoint", () => {
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const observation = await getObservationById(
|
||||
observationId,
|
||||
const observation = await getObservationById({
|
||||
id: observationId,
|
||||
projectId,
|
||||
true,
|
||||
);
|
||||
fetchWithInputOutput: true,
|
||||
});
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation!.id).toBe(observationId);
|
||||
expect(JSON.stringify(observation!.metadata)).toBe(
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
makeZodVerifiedAPICall,
|
||||
makeZodVerifiedAPICallSilent,
|
||||
makeAPICall,
|
||||
} from "@/src/__tests__/test-utils";
|
||||
import { GetMetricsV1Response } from "@/src/features/public-api/types/metrics";
|
||||
import { createBasicAuthHeader } from "@langfuse/shared/src/server";
|
||||
import { type QueryType } from "@/src/features/query/types";
|
||||
import {
|
||||
createTrace,
|
||||
createObservation,
|
||||
createTracesCh,
|
||||
createObservationsCh,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
describe("/api/public/metrics API Endpoint", () => {
|
||||
// Test setup variables
|
||||
const testTraces: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
timestamp: Date;
|
||||
metadata?: Record<string, any>;
|
||||
}> = [];
|
||||
const testMetadataValue = randomUUID();
|
||||
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
// Current time for traces
|
||||
const tomorrow = new Date(new Date().getTime() + 3600 * 24 * 1000);
|
||||
const now = new Date();
|
||||
const yesterday = new Date(new Date().getTime() - 3600 * 24 * 1000);
|
||||
const twoDaysAgo = new Date(new Date().getTime() - 3600 * 24 * 2 * 1000);
|
||||
|
||||
// Set up test data before running tests
|
||||
beforeAll(async () => {
|
||||
// Create test traces with different names
|
||||
const trace1Id = randomUUID();
|
||||
const trace2Id = randomUUID();
|
||||
const trace3Id = randomUUID();
|
||||
|
||||
// Create traces with different names and timestamps
|
||||
testTraces.push(
|
||||
{
|
||||
id: trace1Id,
|
||||
name: "trace-test-1",
|
||||
timestamp: now,
|
||||
metadata: { test: testMetadataValue },
|
||||
},
|
||||
{
|
||||
id: trace2Id,
|
||||
name: "trace-test-2",
|
||||
timestamp: yesterday,
|
||||
metadata: { test: testMetadataValue },
|
||||
},
|
||||
{
|
||||
id: trace3Id,
|
||||
name: "trace-test-3",
|
||||
timestamp: yesterday,
|
||||
metadata: { test: testMetadataValue },
|
||||
},
|
||||
);
|
||||
|
||||
// Insert traces into database
|
||||
await createTracesCh(
|
||||
testTraces.map((trace) =>
|
||||
createTrace({
|
||||
id: trace.id,
|
||||
name: trace.name,
|
||||
project_id: projectId,
|
||||
timestamp: trace.timestamp.getTime(),
|
||||
metadata: trace.metadata || {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Create observations for trace1
|
||||
const trace1Observations = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
trace1Observations.push(
|
||||
createObservation({
|
||||
id: randomUUID(),
|
||||
trace_id: trace1Id,
|
||||
project_id: projectId,
|
||||
name: `observation-${i}`,
|
||||
start_time: now.getTime(),
|
||||
metadata: { test: testMetadataValue },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Create observations for trace2
|
||||
const trace2Observations = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
trace2Observations.push(
|
||||
createObservation({
|
||||
id: randomUUID(),
|
||||
trace_id: trace2Id,
|
||||
project_id: projectId,
|
||||
name: `observation-${i}`,
|
||||
start_time: yesterday.getTime(),
|
||||
metadata: { test: testMetadataValue },
|
||||
}),
|
||||
);
|
||||
}
|
||||
await createObservationsCh([...trace1Observations, ...trace2Observations]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"simple trace query",
|
||||
{
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "metadata",
|
||||
operator: "contains",
|
||||
key: "test",
|
||||
value: testMetadataValue,
|
||||
type: "stringObject",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: twoDaysAgo.toISOString(),
|
||||
toTimestamp: tomorrow.toISOString(),
|
||||
orderBy: null,
|
||||
} as QueryType,
|
||||
// Expected result structure (data will be dummy)
|
||||
{
|
||||
dataLength: 3,
|
||||
expectedMetrics: ["count_count"],
|
||||
expectedDimensions: ["name"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"query with time dimension",
|
||||
{
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "metadata",
|
||||
operator: "contains",
|
||||
key: "test",
|
||||
value: testMetadataValue,
|
||||
type: "stringObject",
|
||||
},
|
||||
],
|
||||
timeDimension: {
|
||||
granularity: "day",
|
||||
},
|
||||
fromTimestamp: twoDaysAgo.toISOString(),
|
||||
toTimestamp: tomorrow.toISOString(),
|
||||
orderBy: null,
|
||||
} as QueryType,
|
||||
// Expected result structure
|
||||
{
|
||||
dataLength: 4,
|
||||
expectedMetrics: ["count_count"],
|
||||
expectedDimensions: ["time_dimension", "name"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"observations query with multiple metrics",
|
||||
{
|
||||
view: "observations",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [
|
||||
{ measure: "count", aggregation: "count" },
|
||||
{ measure: "latency", aggregation: "p95" },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
column: "metadata",
|
||||
operator: "contains",
|
||||
key: "test",
|
||||
value: testMetadataValue,
|
||||
type: "stringObject",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: twoDaysAgo.toISOString(),
|
||||
toTimestamp: tomorrow.toISOString(),
|
||||
orderBy: null,
|
||||
} as QueryType,
|
||||
// Expected result structure
|
||||
{
|
||||
dataLength: 3,
|
||||
expectedMetrics: ["count_count", "p95_latency"],
|
||||
expectedDimensions: ["name"],
|
||||
},
|
||||
],
|
||||
])(
|
||||
"should accept queries and return expected results: %s",
|
||||
async (
|
||||
_name: string,
|
||||
queryObject: QueryType,
|
||||
expectedResult: {
|
||||
dataLength: number;
|
||||
expectedMetrics: string[];
|
||||
expectedDimensions: string[];
|
||||
},
|
||||
) => {
|
||||
// Add pagination parameters needed for the API
|
||||
const fullQuery = {
|
||||
...queryObject,
|
||||
// page: 1,
|
||||
// limit: 10,
|
||||
};
|
||||
|
||||
// Make the API call
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
GetMetricsV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(fullQuery))}`,
|
||||
);
|
||||
|
||||
// Validate response format
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
|
||||
// Validate response matches expected structure
|
||||
expect(response.body.data).toHaveLength(expectedResult.dataLength);
|
||||
response.body.data.forEach((item) => {
|
||||
expect(
|
||||
expectedResult.expectedMetrics.every((metric) => metric in item),
|
||||
).toBe(true);
|
||||
expect(
|
||||
expectedResult.expectedDimensions.every(
|
||||
(dimension) => dimension in item,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("should return 401 with invalid authentication", async () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
// Create the query object
|
||||
const query = {
|
||||
view: "traces",
|
||||
metrics: [
|
||||
{
|
||||
measure: "totalCount",
|
||||
aggregation: "count",
|
||||
},
|
||||
],
|
||||
fromTimestamp: yesterday.toISOString(),
|
||||
toTimestamp: now.toISOString(),
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const invalidAuth = createBasicAuthHeader(
|
||||
"invalid-project-key",
|
||||
"invalid-secret-key",
|
||||
);
|
||||
|
||||
const { status } = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(query))}`,
|
||||
undefined,
|
||||
invalidAuth,
|
||||
);
|
||||
|
||||
expect(status).toBe(401);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid query parameters", async () => {
|
||||
// Test missing required parameters by sending an invalid JSON string
|
||||
const { status } = await makeZodVerifiedAPICallSilent(
|
||||
GetMetricsV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics?query=invalid-json-string`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 400 for incomplete query object", async () => {
|
||||
// Missing required fields in the query object
|
||||
const incompleteQuery = {
|
||||
view: "traces",
|
||||
// Missing metrics, fromTimestamp, toTimestamp
|
||||
};
|
||||
|
||||
const { status } = await makeZodVerifiedAPICallSilent(
|
||||
GetMetricsV1Response,
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(incompleteQuery))}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid metric measure", async () => {
|
||||
// Build a query with an invalid metric measure (name)
|
||||
const invalidMetricNameQuery = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "invalidMetric", aggregation: "count" }],
|
||||
fromTimestamp: new Date(
|
||||
new Date().getTime() - 3600 * 24 * 1000,
|
||||
).toISOString(),
|
||||
toTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { status, body } = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidMetricNameQuery))}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toHaveProperty("error");
|
||||
expect(body.message).toMatch(/Invalid metric/);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid metric aggregation", async () => {
|
||||
// Build a query with an invalid aggregation method
|
||||
const invalidAggregationQuery = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "invalidAggregation" }],
|
||||
fromTimestamp: new Date(
|
||||
new Date().getTime() - 3600 * 24 * 1000,
|
||||
).toISOString(),
|
||||
toTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { status, body } = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidAggregationQuery))}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toHaveProperty("error");
|
||||
expect(body.message).toMatch(/Invalid request data/);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid dimension", async () => {
|
||||
// Build a query with an invalid dimension
|
||||
const invalidDimensionQuery = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "nonExistentDimension" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
fromTimestamp: new Date(
|
||||
new Date().getTime() - 3600 * 24 * 1000,
|
||||
).toISOString(),
|
||||
toTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { status, body } = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidDimensionQuery))}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toHaveProperty("error");
|
||||
expect(body.message).toMatch(/Invalid dimension/);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid filter column", async () => {
|
||||
// Build a query with an invalid filter column
|
||||
const invalidFilterQuery = {
|
||||
view: "traces",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "nonExistentColumn",
|
||||
operator: "=",
|
||||
value: "test",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
fromTimestamp: new Date(
|
||||
new Date().getTime() - 3600 * 24 * 1000,
|
||||
).toISOString(),
|
||||
toTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { status, body } = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/metrics?query=${encodeURIComponent(JSON.stringify(invalidFilterQuery))}`,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toHaveProperty("error");
|
||||
expect(body.message).toMatch(/Invalid filter column/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/** @jest-environment node */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { pruneDatabase } from "@/src/__tests__/test-utils";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
describe("organization API keys trpc", () => {
|
||||
const organizationId = "seed-org-id";
|
||||
|
||||
beforeEach(async () => await pruneDatabase());
|
||||
|
||||
const ownerSession: Session = {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
canCreateOrganizations: true,
|
||||
name: "Demo User",
|
||||
organizations: [
|
||||
{
|
||||
id: organizationId,
|
||||
name: "Test Organization",
|
||||
role: "OWNER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
metadata: {},
|
||||
projects: [],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
excludeClickhouseRead: false,
|
||||
templateFlag: true,
|
||||
},
|
||||
admin: true,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const memberSession: Session = {
|
||||
expires: "1",
|
||||
user: {
|
||||
id: "user-2",
|
||||
canCreateOrganizations: true,
|
||||
name: "Member User",
|
||||
organizations: [
|
||||
{
|
||||
id: organizationId,
|
||||
name: "Test Organization",
|
||||
role: "MEMBER",
|
||||
plan: "cloud:hobby",
|
||||
cloudConfig: undefined,
|
||||
metadata: {},
|
||||
projects: [],
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
excludeClickhouseRead: false,
|
||||
templateFlag: true,
|
||||
},
|
||||
admin: false,
|
||||
},
|
||||
environment: {} as any,
|
||||
};
|
||||
|
||||
const ownerCtx = createInnerTRPCContext({ session: ownerSession });
|
||||
const ownerCaller = appRouter.createCaller({ ...ownerCtx, prisma });
|
||||
|
||||
const memberCtx = createInnerTRPCContext({ session: memberSession });
|
||||
const memberCaller = appRouter.createCaller({ ...memberCtx, prisma });
|
||||
|
||||
const unAuthedCtx = createInnerTRPCContext({ session: null });
|
||||
const unAuthedCaller = appRouter.createCaller({ ...unAuthedCtx, prisma });
|
||||
|
||||
describe("organizationApiKeys.byOrganizationId", () => {
|
||||
it("owner can fetch organization API keys", async () => {
|
||||
// Create a test API key first
|
||||
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "Test API Key",
|
||||
});
|
||||
|
||||
expect(apiKeyResult).toBeDefined();
|
||||
expect(apiKeyResult.secretKey).toBeDefined();
|
||||
expect(apiKeyResult.publicKey).toBeDefined();
|
||||
|
||||
// Now fetch the keys
|
||||
const apiKeys = await ownerCaller.organizationApiKeys.byOrganizationId({
|
||||
orgId: organizationId,
|
||||
});
|
||||
|
||||
expect(apiKeys.length).toBeGreaterThanOrEqual(1);
|
||||
const newKey = apiKeys.find((key) => key.id === apiKeyResult.id);
|
||||
expect(newKey?.note).toBe("Test API Key");
|
||||
expect(newKey?.publicKey).toBe(apiKeyResult.publicKey);
|
||||
expect(newKey?.displaySecretKey).toBeDefined();
|
||||
});
|
||||
|
||||
it("regular member cannot fetch organization API keys", async () => {
|
||||
await expect(
|
||||
memberCaller.organizationApiKeys.byOrganizationId({
|
||||
orgId: organizationId,
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("unauthenticated user cannot fetch organization API keys", async () => {
|
||||
await expect(
|
||||
unAuthedCaller.organizationApiKeys.byOrganizationId({
|
||||
orgId: organizationId,
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("organizationApiKeys.create", () => {
|
||||
it("owner can create organization API keys", async () => {
|
||||
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "Test API Key",
|
||||
});
|
||||
|
||||
expect(apiKeyResult).toBeDefined();
|
||||
expect(apiKeyResult.secretKey).toBeDefined();
|
||||
expect(apiKeyResult.publicKey).toBeDefined();
|
||||
expect(apiKeyResult.note).toBe("Test API Key");
|
||||
});
|
||||
|
||||
it("regular member cannot create organization API keys", async () => {
|
||||
await expect(
|
||||
memberCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "Test API Key",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("unauthenticated user cannot create organization API keys", async () => {
|
||||
await expect(
|
||||
unAuthedCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "Test API Key",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("organizationApiKeys.updateNote", () => {
|
||||
it("owner can update API key note", async () => {
|
||||
// Create a key first
|
||||
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "Original Note",
|
||||
});
|
||||
|
||||
// Update the note
|
||||
await ownerCaller.organizationApiKeys.updateNote({
|
||||
orgId: organizationId,
|
||||
keyId: apiKeyResult.id,
|
||||
note: "Updated Note",
|
||||
});
|
||||
|
||||
// Fetch to verify
|
||||
const apiKeys = await ownerCaller.organizationApiKeys.byOrganizationId({
|
||||
orgId: organizationId,
|
||||
});
|
||||
|
||||
expect(apiKeys.length).toBeGreaterThanOrEqual(1);
|
||||
const updatedKey = apiKeys.find((key) => key.id === apiKeyResult.id);
|
||||
expect(updatedKey?.note).toBe("Updated Note");
|
||||
});
|
||||
|
||||
it("regular member cannot update API key note", async () => {
|
||||
// Create a key as owner
|
||||
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "Original Note",
|
||||
});
|
||||
|
||||
// Try to update as member
|
||||
await expect(
|
||||
memberCaller.organizationApiKeys.updateNote({
|
||||
orgId: organizationId,
|
||||
keyId: apiKeyResult.id,
|
||||
note: "Updated Note",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("organizationApiKeys.delete", () => {
|
||||
it("owner can delete API key", async () => {
|
||||
// Create a key first
|
||||
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "To Be Deleted",
|
||||
});
|
||||
|
||||
// Delete the key
|
||||
await ownerCaller.organizationApiKeys.delete({
|
||||
orgId: organizationId,
|
||||
id: apiKeyResult.id,
|
||||
});
|
||||
|
||||
// Verify it's gone
|
||||
const apiKeys = await ownerCaller.organizationApiKeys.byOrganizationId({
|
||||
orgId: organizationId,
|
||||
});
|
||||
|
||||
const deletedKey = apiKeys.find((key) => key.id === apiKeyResult.id);
|
||||
expect(deletedKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("regular member cannot delete API key", async () => {
|
||||
// Create a key as owner
|
||||
const apiKeyResult = await ownerCaller.organizationApiKeys.create({
|
||||
orgId: organizationId,
|
||||
note: "To Be Deleted",
|
||||
});
|
||||
|
||||
// Try to delete as member
|
||||
await expect(
|
||||
memberCaller.organizationApiKeys.delete({
|
||||
orgId: organizationId,
|
||||
id: apiKeyResult.id,
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
createAndAddApiKeysToDb,
|
||||
createBasicAuthHeader,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
// Schema for organization response
|
||||
const OrganizationResponseSchema = z.object({
|
||||
@@ -740,3 +744,292 @@ describe("Admin Organizations API", () => {
|
||||
expect(result.body.error).toContain("Method Not Allowed");
|
||||
});
|
||||
});
|
||||
|
||||
// Schema for organization project response
|
||||
const OrganizationProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
// Schema for organization projects list response
|
||||
const OrganizationProjectsListSchema = z.object({
|
||||
projects: z.array(OrganizationProjectSchema),
|
||||
});
|
||||
|
||||
describe("Public Organizations API", () => {
|
||||
// Create test data
|
||||
let testOrgId: string;
|
||||
let testProject1Id: string;
|
||||
let testProject2Id: string;
|
||||
let testApiKey: string;
|
||||
let testApiSecretKey: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a test organization
|
||||
const uniqueOrgName = `Test Org ${randomUUID().substring(0, 8)}`;
|
||||
const org = await prisma.organization.create({
|
||||
data: { name: uniqueOrgName, cloudConfig: { plan: "Team" } },
|
||||
});
|
||||
testOrgId = org.id;
|
||||
|
||||
// Create test projects
|
||||
const uniqueProject1Name = `Test Project 1 ${randomUUID().substring(0, 8)}`;
|
||||
const project1 = await prisma.project.create({
|
||||
data: {
|
||||
name: uniqueProject1Name,
|
||||
orgId: testOrgId,
|
||||
metadata: { type: "test", environment: "development" },
|
||||
},
|
||||
});
|
||||
testProject1Id = project1.id;
|
||||
|
||||
const uniqueProject2Name = `Test Project 2 ${randomUUID().substring(0, 8)}`;
|
||||
const project2 = await prisma.project.create({
|
||||
data: {
|
||||
name: uniqueProject2Name,
|
||||
orgId: testOrgId,
|
||||
metadata: { type: "test", environment: "production" },
|
||||
},
|
||||
});
|
||||
testProject2Id = project2.id;
|
||||
|
||||
// Create an organization API key
|
||||
const apiKey = await createAndAddApiKeysToDb({
|
||||
prisma,
|
||||
entityId: testOrgId,
|
||||
scope: "ORGANIZATION",
|
||||
note: "Test API Key for Organizations API",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-lf-org-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-org-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
testApiKey = apiKey.publicKey;
|
||||
testApiSecretKey = apiKey.secretKey;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test data
|
||||
await prisma.organization.delete({
|
||||
where: {
|
||||
id: testOrgId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Projects", () => {
|
||||
describe("GET /api/public/organizations/projects", () => {
|
||||
it("should get all organization projects with valid API key", async () => {
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationProjectsListSchema,
|
||||
"GET",
|
||||
`/api/public/organizations/projects`,
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body.projects)).toBe(true);
|
||||
expect(response.body.projects.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify our test projects are in the response
|
||||
const projectIds = response.body.projects.map((p) => p.id);
|
||||
expect(projectIds).toContain(testProject1Id);
|
||||
expect(projectIds).toContain(testProject2Id);
|
||||
|
||||
// Verify project structure
|
||||
const project1 = response.body.projects.find(
|
||||
(p) => p.id === testProject1Id,
|
||||
);
|
||||
expect(project1).toBeDefined();
|
||||
expect(project1?.name).toBeTruthy();
|
||||
expect(project1?.metadata).toEqual({
|
||||
type: "test",
|
||||
environment: "development",
|
||||
});
|
||||
expect(project1?.createdAt).toBeTruthy();
|
||||
expect(project1?.updatedAt).toBeTruthy();
|
||||
|
||||
const project2 = response.body.projects.find(
|
||||
(p) => p.id === testProject2Id,
|
||||
);
|
||||
expect(project2).toBeDefined();
|
||||
expect(project2?.metadata).toEqual({
|
||||
type: "test",
|
||||
environment: "production",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 403 when using a project-scoped API key", async () => {
|
||||
// Create a project API key
|
||||
const projectApiKey = await createAndAddApiKeysToDb({
|
||||
prisma,
|
||||
entityId: testProject1Id,
|
||||
scope: "PROJECT",
|
||||
note: "Test Project API Key",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-lf-project-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-project-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/organizations/projects`,
|
||||
undefined,
|
||||
createBasicAuthHeader(
|
||||
projectApiKey.publicKey,
|
||||
projectApiKey.secretKey,
|
||||
),
|
||||
);
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.body.error).toContain(
|
||||
"Organization-scoped API key required",
|
||||
);
|
||||
|
||||
// Clean up
|
||||
await prisma.apiKey.delete({
|
||||
where: {
|
||||
id: projectApiKey.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 401 when using invalid API key", async () => {
|
||||
const result = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/organizations/projects`,
|
||||
undefined,
|
||||
createBasicAuthHeader("invalid-key", "invalid-secret"),
|
||||
);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should return 405 for non-GET methods", async () => {
|
||||
const result = await makeAPICall(
|
||||
"POST",
|
||||
`/api/public/organizations/projects`,
|
||||
{ some: "data" },
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(405);
|
||||
expect(result.body.error).toBe("Method not allowed");
|
||||
});
|
||||
|
||||
it("should only return projects belonging to the organization", async () => {
|
||||
// Create another organization with projects
|
||||
const otherOrg = await prisma.organization.create({
|
||||
data: {
|
||||
name: `Other Org ${randomUUID().substring(0, 8)}`,
|
||||
cloudConfig: { plan: "Team" },
|
||||
},
|
||||
});
|
||||
|
||||
const otherProject = await prisma.project.create({
|
||||
data: {
|
||||
name: `Other Project ${randomUUID().substring(0, 8)}`,
|
||||
orgId: otherOrg.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Get projects for our test organization
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationProjectsListSchema,
|
||||
"GET",
|
||||
`/api/public/organizations/projects`,
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
// Verify only our organization's projects are returned
|
||||
const projectIds = response.body.projects.map((p) => p.id);
|
||||
expect(projectIds).toContain(testProject1Id);
|
||||
expect(projectIds).toContain(testProject2Id);
|
||||
expect(projectIds).not.toContain(otherProject.id);
|
||||
|
||||
// Clean up
|
||||
await prisma.organization.delete({
|
||||
where: {
|
||||
id: otherOrg.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle organization with no projects", async () => {
|
||||
// Create an empty organization
|
||||
const emptyOrg = await prisma.organization.create({
|
||||
data: {
|
||||
name: `Empty Org ${randomUUID().substring(0, 8)}`,
|
||||
cloudConfig: { plan: "Team" },
|
||||
},
|
||||
});
|
||||
|
||||
// Create API key for empty organization
|
||||
const emptyOrgApiKey = await createAndAddApiKeysToDb({
|
||||
prisma,
|
||||
entityId: emptyOrg.id,
|
||||
scope: "ORGANIZATION",
|
||||
note: "Test API Key for Empty Org",
|
||||
predefinedKeys: {
|
||||
publicKey: `pk-lf-empty-${randomUUID().substring(0, 8)}`,
|
||||
secretKey: `sk-lf-empty-${randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationProjectsListSchema,
|
||||
"GET",
|
||||
`/api/public/organizations/projects`,
|
||||
undefined,
|
||||
createBasicAuthHeader(
|
||||
emptyOrgApiKey.publicKey,
|
||||
emptyOrgApiKey.secretKey,
|
||||
),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.projects).toEqual([]);
|
||||
|
||||
// Clean up
|
||||
await prisma.organization.delete({
|
||||
where: {
|
||||
id: emptyOrg.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle projects with null metadata", async () => {
|
||||
// Create a project with null metadata
|
||||
const projectWithNullMetadata = await prisma.project.create({
|
||||
data: {
|
||||
name: `Null Metadata Project ${randomUUID().substring(0, 8)}`,
|
||||
orgId: testOrgId,
|
||||
metadata: null,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
OrganizationProjectsListSchema,
|
||||
"GET",
|
||||
`/api/public/organizations/projects`,
|
||||
undefined,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
200,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const nullMetadataProject = response.body.projects.find(
|
||||
(p) => p.id === projectWithNullMetadata.id,
|
||||
);
|
||||
expect(nullMetadataProject).toBeDefined();
|
||||
expect(nullMetadataProject?.metadata).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,13 +239,13 @@ describe("Projects API", () => {
|
||||
it("should validate retention days", async () => {
|
||||
const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`;
|
||||
|
||||
// Test with invalid retention days (less than 7 and not 0)
|
||||
// Test with invalid retention days (less than 3 and not 0)
|
||||
const invalidResult = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/projects",
|
||||
{
|
||||
name: uniqueProjectName,
|
||||
retention: 5, // Invalid: less than 7 and not 0
|
||||
retention: 2, // Invalid: less than 3 and not 0
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
);
|
||||
@@ -390,13 +390,13 @@ describe("Projects API", () => {
|
||||
});
|
||||
|
||||
it("should validate retention days on update", async () => {
|
||||
// Test with invalid retention days (less than 7 and not 0)
|
||||
// Test with invalid retention days (less than 3 and not 0)
|
||||
const invalidResult = await makeAPICall(
|
||||
"PUT",
|
||||
`/api/public/projects/${testProjectId}`,
|
||||
{
|
||||
name: "Updated Project Name",
|
||||
retention: 5, // Invalid: less than 7 and not 0
|
||||
retention: 2, // Invalid: less than 3 and not 0
|
||||
},
|
||||
createBasicAuthHeader(orgApiKey, orgSecretKey),
|
||||
);
|
||||
|
||||
@@ -17,7 +17,12 @@ describe("Clickhouse Observations Repository Test", () => {
|
||||
});
|
||||
|
||||
it("should throw if no observations are found", async () => {
|
||||
await expect(getObservationById(v4(), v4())).rejects.toThrow();
|
||||
await expect(
|
||||
getObservationById({
|
||||
id: v4(),
|
||||
projectId: v4(),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should return an observation if exists", async () => {
|
||||
@@ -58,14 +63,21 @@ describe("Clickhouse Observations Repository Test", () => {
|
||||
|
||||
await createObservationsCh([observation]);
|
||||
|
||||
const result = await getObservationById(observationId, projectId, true);
|
||||
const result = await getObservationById({
|
||||
id: observationId,
|
||||
projectId,
|
||||
fetchWithInputOutput: true,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Observation not found");
|
||||
}
|
||||
|
||||
expect(result.id).toEqual(observation.id);
|
||||
expect(result.traceId).toEqual(observation.trace_id);
|
||||
expect(result.projectId).toEqual(observation.project_id);
|
||||
expect(result.type).toEqual(observation.type);
|
||||
|
||||
expect(result.metadata).toEqual(observation.metadata);
|
||||
expect(result.createdAt).toEqual(new Date(observation.created_at));
|
||||
expect(result.updatedAt).toEqual(new Date(observation.updated_at));
|
||||
@@ -88,6 +100,59 @@ describe("Clickhouse Observations Repository Test", () => {
|
||||
expect(result.outputUsage).toEqual(5678);
|
||||
expect(result.totalUsage).toEqual(6912);
|
||||
});
|
||||
|
||||
it("should return an observation if exists by traceId", async () => {
|
||||
const observationId = v4();
|
||||
const traceId = v4();
|
||||
|
||||
const observation = createObservation({
|
||||
id: observationId,
|
||||
trace_id: traceId,
|
||||
project_id: projectId,
|
||||
type: "sample_type",
|
||||
metadata: {},
|
||||
provided_usage_details: { input: 1234, output: 5678, total: 6912 },
|
||||
provided_cost_details: { input: 100, output: 200, total: 300 },
|
||||
usage_details: { input: 1234, output: 5678, total: 6912 },
|
||||
cost_details: { input: 100, output: 200, total: 300 },
|
||||
is_deleted: 0,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
start_time: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
name: "sample_name",
|
||||
level: "sample_level",
|
||||
status_message: "sample_status",
|
||||
version: "1.0",
|
||||
input: "sample_input",
|
||||
output: "sample_output",
|
||||
provided_model_name: "sample_model",
|
||||
internal_model_id: "sample_internal_model_id",
|
||||
model_parameters: '{"something":"sample_param"}',
|
||||
total_cost: 300,
|
||||
prompt_id: "sample_prompt_id",
|
||||
prompt_name: "sample_prompt_name",
|
||||
prompt_version: 1,
|
||||
end_time: Date.now(),
|
||||
completion_start_time: Date.now(),
|
||||
});
|
||||
|
||||
await createObservationsCh([observation]);
|
||||
|
||||
const result = await getObservationById({
|
||||
id: observationId,
|
||||
projectId,
|
||||
traceId,
|
||||
fetchWithInputOutput: true,
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("Observation not found");
|
||||
}
|
||||
expect(result.id).toEqual(observation.id);
|
||||
expect(result.traceId).toEqual(observation.trace_id);
|
||||
expect(result.projectId).toEqual(observation.project_id);
|
||||
expect(result.type).toEqual(observation.type);
|
||||
});
|
||||
it("should return an observation view", async () => {
|
||||
const observationId = v4();
|
||||
const traceId = v4();
|
||||
|
||||
@@ -16,13 +16,15 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
});
|
||||
|
||||
it("should throw if no traces are found", async () => {
|
||||
expect(await getTraceById({ traceId: v4(), projectId: v4() })).toBeUndefined();
|
||||
expect(
|
||||
await getTraceById({ traceId: v4(), projectId: v4() }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return a trace if it exists", async () => {
|
||||
const traceId = v4();
|
||||
|
||||
const trace = {
|
||||
const trace = createTrace({
|
||||
id: traceId,
|
||||
project_id: projectId,
|
||||
session_id: v4(),
|
||||
@@ -47,7 +49,7 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
updated_at: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
@@ -71,7 +73,7 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
expect(result.userId).toEqual(trace.user_id);
|
||||
expect(result.sessionId).toEqual(trace.session_id);
|
||||
expect(result.public).toEqual(trace.public);
|
||||
expect(result.input).toEqual(JSON.parse(trace.input));
|
||||
expect(result.input).toEqual(JSON.parse(trace.input ?? "{}"));
|
||||
expect(result.output).toEqual("regular string");
|
||||
expect(result.metadata).toEqual(trace.metadata);
|
||||
expect(result.createdAt).toEqual(new Date(trace.created_at));
|
||||
@@ -81,7 +83,7 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
it("should find a trace if no timestamp is provided", async () => {
|
||||
const traceId = v4();
|
||||
|
||||
const trace = {
|
||||
const trace = createTrace({
|
||||
id: traceId,
|
||||
project_id: projectId,
|
||||
session_id: v4(),
|
||||
@@ -98,7 +100,7 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
updated_at: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
});
|
||||
|
||||
await createTracesCh([trace]);
|
||||
|
||||
@@ -126,7 +128,7 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
});
|
||||
it("should retrieve traces by session ID", async () => {
|
||||
const sessionId = v4();
|
||||
const trace1 = {
|
||||
const trace1 = createTrace({
|
||||
id: v4(),
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
@@ -143,9 +145,9 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
updated_at: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const trace2 = {
|
||||
const trace2 = createTrace({
|
||||
id: v4(),
|
||||
project_id: projectId,
|
||||
session_id: sessionId,
|
||||
@@ -162,7 +164,7 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
updated_at: Date.now(),
|
||||
event_ts: Date.now(),
|
||||
is_deleted: 0,
|
||||
};
|
||||
});
|
||||
|
||||
await createTracesCh([trace1, trace2]);
|
||||
|
||||
@@ -212,14 +214,20 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
await createTracesCh([trace]);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const exists = await checkTraceExists(projectId, traceId, new Date(), [
|
||||
{
|
||||
type: "stringOptions",
|
||||
column: "level",
|
||||
operator: "any of",
|
||||
value: ["ERROR"],
|
||||
},
|
||||
]);
|
||||
const exists = await checkTraceExists({
|
||||
projectId,
|
||||
traceId,
|
||||
timestamp: new Date(),
|
||||
filter: [
|
||||
{
|
||||
type: "stringOptions",
|
||||
column: "level",
|
||||
operator: "any of",
|
||||
value: ["ERROR"],
|
||||
},
|
||||
],
|
||||
maxTimeStamp: undefined,
|
||||
});
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
@@ -261,14 +269,20 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
await createTracesCh([trace]);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const exists = await checkTraceExists(projectId, traceId, new Date(), [
|
||||
{
|
||||
type: "stringOptions",
|
||||
column: "level",
|
||||
operator: "none of",
|
||||
value: ["ERROR"],
|
||||
},
|
||||
]);
|
||||
const exists = await checkTraceExists({
|
||||
projectId,
|
||||
traceId,
|
||||
timestamp: new Date(),
|
||||
filter: [
|
||||
{
|
||||
type: "stringOptions",
|
||||
column: "level",
|
||||
operator: "none of",
|
||||
value: ["ERROR"],
|
||||
},
|
||||
],
|
||||
maxTimeStamp: undefined,
|
||||
});
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
it("should check if trace exists without level filter", async () => {
|
||||
@@ -309,7 +323,13 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
await createTracesCh([trace]);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const exists = await checkTraceExists(projectId, traceId, new Date(), []);
|
||||
const exists = await checkTraceExists({
|
||||
projectId,
|
||||
traceId,
|
||||
timestamp: new Date(),
|
||||
filter: [],
|
||||
maxTimeStamp: undefined,
|
||||
});
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
it("should check if trace exists with error level count > 0", async () => {
|
||||
@@ -350,14 +370,20 @@ describe("Clickhouse Traces Repository Test", () => {
|
||||
await createTracesCh([trace]);
|
||||
await createObservationsCh(observations);
|
||||
|
||||
const exists = await checkTraceExists(projectId, traceId, new Date(), [
|
||||
{
|
||||
type: "number",
|
||||
column: "errorCount",
|
||||
operator: ">",
|
||||
value: 0,
|
||||
},
|
||||
]);
|
||||
const exists = await checkTraceExists({
|
||||
projectId,
|
||||
traceId,
|
||||
timestamp: new Date(),
|
||||
filter: [
|
||||
{
|
||||
type: "number",
|
||||
column: "errorCount",
|
||||
operator: ">",
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
maxTimeStamp: undefined,
|
||||
});
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,7 +187,7 @@ describe("Media Upload API", () => {
|
||||
} finally {
|
||||
if (mediaId) {
|
||||
result.mediaRecord = await prisma.media.findUnique({
|
||||
where: { id: mediaId },
|
||||
where: { projectId_id: { id: mediaId, projectId } },
|
||||
});
|
||||
result.traceMediaRecord = await prisma.traceMedia.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -38,6 +38,13 @@ describe("/models API Endpoints", () => {
|
||||
unit: "TOKENS",
|
||||
},
|
||||
});
|
||||
await prisma.price.createMany({
|
||||
data: [
|
||||
{ modelId: "model-1", usageType: "input", price: 0.001 },
|
||||
{ modelId: "model-1", usageType: "output", price: 0.002 },
|
||||
{ modelId: "model-1", usageType: "total", price: 0.1 },
|
||||
],
|
||||
});
|
||||
await prisma.model.create({
|
||||
data: {
|
||||
id: "model-2",
|
||||
@@ -51,6 +58,12 @@ describe("/models API Endpoints", () => {
|
||||
unit: "TOKENS",
|
||||
},
|
||||
});
|
||||
await prisma.price.createMany({
|
||||
data: [
|
||||
{ modelId: "model-2", usageType: "input", price: 0.002 },
|
||||
{ modelId: "model-2", usageType: "output", price: 0.004 },
|
||||
],
|
||||
});
|
||||
});
|
||||
afterEach(async () => await pruneDatabase());
|
||||
|
||||
@@ -67,6 +80,11 @@ describe("/models API Endpoints", () => {
|
||||
expect(models.body.data[0]).toMatchObject({
|
||||
isLangfuseManaged: true,
|
||||
modelName: "gpt-3.5-turbo",
|
||||
prices: {
|
||||
input: { price: 0.001 },
|
||||
output: { price: 0.002 },
|
||||
total: { price: 0.1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +123,10 @@ describe("/models API Endpoints", () => {
|
||||
auth,
|
||||
);
|
||||
expect(customModel.body.isLangfuseManaged).toBe(false);
|
||||
expect(customModel.body.prices).toMatchObject({
|
||||
input: { price: 0.002 },
|
||||
output: { price: 0.004 },
|
||||
});
|
||||
|
||||
const models = await makeZodVerifiedAPICall(
|
||||
GetModelsV1Response,
|
||||
@@ -132,6 +154,10 @@ describe("/models API Endpoints", () => {
|
||||
unit: "TOKENS",
|
||||
tokenizerConfig: { tokensPerMessage: 3, tokensPerName: 1 },
|
||||
isLangfuseManaged: false,
|
||||
prices: {
|
||||
input: { price: 0.002 },
|
||||
output: { price: 0.004 },
|
||||
},
|
||||
});
|
||||
|
||||
const prices = await prisma.price.findMany({
|
||||
|
||||
@@ -464,7 +464,10 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
// Delay to allow for async processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const dbGeneration = await getObservationById(generationId, projectId);
|
||||
const dbGeneration = await getObservationById({
|
||||
id: generationId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.promptId).toBe(promptId);
|
||||
@@ -523,7 +526,10 @@ describe("/api/public/prompts API Endpoint", () => {
|
||||
expect(response.status).toBe(207);
|
||||
|
||||
expect(
|
||||
getObservationById(generationId, "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"),
|
||||
getObservationById({
|
||||
id: generationId,
|
||||
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
|
||||
}),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
|
||||
@@ -405,7 +405,10 @@ describe("/api/public/v2/prompts API Endpoint", () => {
|
||||
// Delay to allow for async processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const dbGeneration = await getObservationById(generationId, projectId);
|
||||
const dbGeneration = await getObservationById({
|
||||
id: generationId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(dbGeneration?.id).toBe(generationId);
|
||||
expect(dbGeneration?.promptId).toBe(promptId);
|
||||
|
||||
@@ -2519,6 +2519,83 @@ describe("queryBuilder", () => {
|
||||
expect(result.data[0].name).toBe("score-premium");
|
||||
expect(parseFloat(result.data[0].avg_value)).toBeCloseTo(0.95);
|
||||
});
|
||||
|
||||
it("LFE-4838: should filter scores-numeric by scoreName (fallback handling) without errors", async () => {
|
||||
// Setup
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Create trace
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
name: "score-name-test-trace",
|
||||
environment: "production",
|
||||
});
|
||||
await createTracesCh([trace]);
|
||||
|
||||
// Create scores with different names
|
||||
const scores = [
|
||||
{
|
||||
name: "accuracy",
|
||||
traceId: trace.id,
|
||||
value: 0.9,
|
||||
dataType: "NUMERIC" as const,
|
||||
},
|
||||
{
|
||||
name: "relevance",
|
||||
traceId: trace.id,
|
||||
value: 0.85,
|
||||
dataType: "NUMERIC" as const,
|
||||
},
|
||||
];
|
||||
|
||||
await setupScores(projectId, scores);
|
||||
|
||||
// Define query with filter using "scoreName" instead of "name"
|
||||
// This tests the fallback handling in queryBuilder.ts that handles column names ending with "Name"
|
||||
const query: QueryType = {
|
||||
view: "scores-numeric",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [{ measure: "count", aggregation: "count" }],
|
||||
filters: [
|
||||
{
|
||||
column: "scoreName", // Using scoreName instead of name to test the fallback logic
|
||||
operator: "=",
|
||||
value: "accuracy",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() - 1),
|
||||
).toISOString(),
|
||||
toTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() + 1),
|
||||
).toISOString(),
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
// Execute query
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
// Verify the compiled query contains filtering on name
|
||||
expect(compiledQuery).toContain("scores_numeric.name");
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
|
||||
// Assert - should only return scores with name "accuracy"
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].name).toBe("accuracy");
|
||||
expect(result.data[0].count_count).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scores-categorical view", () => {
|
||||
@@ -2969,6 +3046,228 @@ describe("queryBuilder", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null streamingLatency and timeToFirstToken when completion_start_time is null", async () => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Create trace
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
name: "null-completion-start-time-trace",
|
||||
environment: "default",
|
||||
timestamp: new Date().getTime(),
|
||||
});
|
||||
await createTracesCh([trace]);
|
||||
|
||||
// Create observation with NULL completion_start_time
|
||||
const startTime = new Date();
|
||||
const endTime = new Date(startTime.getTime() + 1000);
|
||||
const observation = createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: trace.id,
|
||||
type: "generation",
|
||||
name: "model-x",
|
||||
provided_model_name: "model-x",
|
||||
environment: "default",
|
||||
start_time: startTime.getTime(),
|
||||
completion_start_time: null, // explicitly null
|
||||
end_time: endTime.getTime(),
|
||||
});
|
||||
await createObservationsCh([observation]);
|
||||
|
||||
// Build query selecting metrics per observation
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [
|
||||
{ measure: "timeToFirstToken", aggregation: "max" },
|
||||
{ measure: "streamingLatency", aggregation: "max" },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
column: "type",
|
||||
operator: "=",
|
||||
value: "generation",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() - 1),
|
||||
).toISOString(),
|
||||
toTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() + 1),
|
||||
).toISOString(),
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const row = result.data[0];
|
||||
expect(row.max_timeToFirstToken).toBeNull();
|
||||
expect(row.max_streamingLatency).toBeNull();
|
||||
});
|
||||
|
||||
it("should return streamingLatency and timeToFirstToken when completion_start_time is present", async () => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Create trace
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
name: "null-completion-start-time-trace",
|
||||
environment: "default",
|
||||
timestamp: new Date().getTime(),
|
||||
});
|
||||
await createTracesCh([trace]);
|
||||
|
||||
// Create observation with NULL completion_start_time
|
||||
const startTime = new Date();
|
||||
const endTime = new Date(startTime.getTime() + 1000);
|
||||
const observation = createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: trace.id,
|
||||
type: "generation",
|
||||
name: "model-x",
|
||||
provided_model_name: "model-x",
|
||||
environment: "default",
|
||||
start_time: startTime.getTime(),
|
||||
completion_start_time: startTime.getTime() + 200,
|
||||
end_time: endTime.getTime(),
|
||||
});
|
||||
await createObservationsCh([observation]);
|
||||
|
||||
// Build query selecting metrics per observation
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [
|
||||
{ measure: "timeToFirstToken", aggregation: "max" },
|
||||
{ measure: "streamingLatency", aggregation: "max" },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
column: "type",
|
||||
operator: "=",
|
||||
value: "generation",
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() - 1),
|
||||
).toISOString(),
|
||||
toTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() + 1),
|
||||
).toISOString(),
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const row = result.data[0];
|
||||
expect(row.max_timeToFirstToken).toBe("200");
|
||||
expect(row.max_streamingLatency).toBe("800");
|
||||
});
|
||||
|
||||
it("should calculate tokens correctly", async () => {
|
||||
const projectId = randomUUID();
|
||||
|
||||
// Create trace
|
||||
const trace = createTrace({
|
||||
project_id: projectId,
|
||||
name: "null-completion-start-time-trace",
|
||||
environment: "default",
|
||||
timestamp: new Date().getTime(),
|
||||
});
|
||||
await createTracesCh([trace]);
|
||||
|
||||
// Create observation with NULL completion_start_time
|
||||
const startTime = new Date();
|
||||
const endTime = new Date(startTime.getTime() + 1000);
|
||||
const observation = createObservation({
|
||||
project_id: projectId,
|
||||
trace_id: trace.id,
|
||||
type: "generation",
|
||||
name: "model-x",
|
||||
provided_model_name: "model-x",
|
||||
environment: "default",
|
||||
start_time: startTime.getTime(),
|
||||
completion_start_time: startTime.getTime() + 200,
|
||||
end_time: endTime.getTime(),
|
||||
usage_details: {
|
||||
input_tokens: 100,
|
||||
input_cache_tokens: 200,
|
||||
output_tokens: 300,
|
||||
output_cache_tokens: 400,
|
||||
total: 1000,
|
||||
},
|
||||
});
|
||||
await createObservationsCh([observation]);
|
||||
|
||||
// Build query selecting metrics per observation
|
||||
const query: QueryType = {
|
||||
view: "observations",
|
||||
dimensions: [{ field: "name" }],
|
||||
metrics: [
|
||||
{ measure: "inputTokens", aggregation: "sum" },
|
||||
{ measure: "outputTokens", aggregation: "sum" },
|
||||
{ measure: "totalTokens", aggregation: "sum" },
|
||||
{ measure: "outputTokensPerSecond", aggregation: "avg" },
|
||||
],
|
||||
filters: [],
|
||||
timeDimension: null,
|
||||
fromTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() - 1),
|
||||
).toISOString(),
|
||||
toTimestamp: new Date(
|
||||
new Date().setDate(new Date().getDate() + 1),
|
||||
).toISOString(),
|
||||
orderBy: null,
|
||||
};
|
||||
|
||||
const queryBuilder = new QueryBuilder();
|
||||
const { query: compiledQuery, parameters } = queryBuilder.build(
|
||||
query,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const result = await (
|
||||
await clickhouseClient().query({
|
||||
query: compiledQuery,
|
||||
query_params: parameters,
|
||||
})
|
||||
).json();
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
const row = result.data[0];
|
||||
expect(row.sum_inputTokens).toBe("300");
|
||||
expect(row.sum_outputTokens).toBe("700");
|
||||
expect(row.sum_totalTokens).toBe("1000");
|
||||
});
|
||||
|
||||
it("should filter observations by metadata correctly", async () => {
|
||||
// Setup
|
||||
const projectId = randomUUID();
|
||||
|
||||
@@ -78,8 +78,8 @@ describe("RateLimitService", () => {
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 20,
|
||||
remainingPoints: 19,
|
||||
points: 30,
|
||||
remainingPoints: 29,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 1,
|
||||
isFirstInDuration: true,
|
||||
@@ -111,8 +111,8 @@ describe("RateLimitService", () => {
|
||||
expect(result?.res).toEqual({
|
||||
scope: scope,
|
||||
resource: "public-api",
|
||||
points: 20,
|
||||
remainingPoints: 18,
|
||||
points: 30,
|
||||
remainingPoints: 28,
|
||||
msBeforeNext: expect.any(Number),
|
||||
consumedPoints: 2,
|
||||
isFirstInDuration: false,
|
||||
|
||||
@@ -5,6 +5,558 @@ import {
|
||||
import { ingestionEvent } from "@langfuse/shared/src/server";
|
||||
|
||||
describe("OTel Resource Span Mapping", () => {
|
||||
describe("Langfuse OTEL SDK spans", () => {
|
||||
const publicKey = "pk-lf-1234567890";
|
||||
|
||||
it("should convert LF-OTEL spans to LF-events", () => {
|
||||
const langfuseOtelSpans = [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "telemetry.sdk.language",
|
||||
value: { stringValue: "python" },
|
||||
},
|
||||
{
|
||||
key: "telemetry.sdk.name",
|
||||
value: { stringValue: "opentelemetry" },
|
||||
},
|
||||
{
|
||||
key: "telemetry.sdk.version",
|
||||
value: { stringValue: "1.32.0" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.environment",
|
||||
value: { stringValue: "production" },
|
||||
},
|
||||
{ key: "langfuse.release", value: { stringValue: "0.0.1" } },
|
||||
{
|
||||
key: "service.name",
|
||||
value: { stringValue: "unknown_service" },
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "langfuse-sdk",
|
||||
version: "2.60.3",
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
44, 206, 24, 247, 232, 205, 6, 90, 11, 78, 99, 78, 239,
|
||||
114, 131, 145,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [87, 240, 37, 84, 23, 151, 65, 189],
|
||||
},
|
||||
parentSpanId: {
|
||||
type: "Buffer",
|
||||
data: [223, 227, 135, 254, 167, 239, 59, 2],
|
||||
},
|
||||
name: "my-generation",
|
||||
kind: 1,
|
||||
startTimeUnixNano: {
|
||||
low: 466848096,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 467248096,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "langfuse.observation.type",
|
||||
value: { stringValue: "generation" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.level",
|
||||
value: { stringValue: "WARNING" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.status_message",
|
||||
value: { stringValue: "nothing to report" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.input",
|
||||
value: {
|
||||
stringValue: '[{"role": "user", "content": "hello"}]',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.output",
|
||||
value: {
|
||||
stringValue:
|
||||
'{"role": "assistant", "content": "what\'s up?"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.model.name",
|
||||
value: { stringValue: "gpt-4o" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.prompt.name",
|
||||
value: { stringValue: "my-prompt" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.prompt.version",
|
||||
value: { intValue: { low: 1, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.usage_details",
|
||||
value: {
|
||||
stringValue:
|
||||
'{"input_tokens": 123, "output_tokens": 456}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.cost_details",
|
||||
value: {
|
||||
stringValue:
|
||||
'{"input_tokens": 0.0001, "output_tokens": 0.002}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.completion_start_time",
|
||||
value: { stringValue: '"2025-04-30T15:28:50.686390Z"' },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.model_parameters",
|
||||
value: {
|
||||
stringValue: '{"temperature": 1, "top_p": "0.2"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.metadata.key1",
|
||||
value: { stringValue: '"value1"' },
|
||||
},
|
||||
{
|
||||
key: "langfuse.observation.metadata.key2",
|
||||
value: { stringValue: '"value2"' },
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.name",
|
||||
value: { stringValue: "test-trace" },
|
||||
},
|
||||
{ key: "user.id", value: { stringValue: "my-user" } },
|
||||
{ key: "session.id", value: { stringValue: "my-session" } },
|
||||
{
|
||||
key: "langfuse.version",
|
||||
value: { stringValue: "trace-0.0.1" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.input",
|
||||
value: {
|
||||
stringValue: '[{"role": "user", "content": "hello"}]',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.output",
|
||||
value: {
|
||||
stringValue:
|
||||
'{"role": "assistant", "content": "what\'s up?"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.tags",
|
||||
value: {
|
||||
arrayValue: { values: [{ stringValue: "tag2" }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.public",
|
||||
value: { boolValue: true },
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.metadata.trace-key1",
|
||||
value: { stringValue: '"value1"' },
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.metadata.trace-key2",
|
||||
value: { stringValue: '"value2"' },
|
||||
},
|
||||
],
|
||||
status: {},
|
||||
},
|
||||
{
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
44, 206, 24, 247, 232, 205, 6, 90, 11, 78, 99, 78, 239,
|
||||
114, 131, 145,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [223, 227, 135, 254, 167, 239, 59, 2],
|
||||
},
|
||||
name: "my-span",
|
||||
kind: 1,
|
||||
startTimeUnixNano: {
|
||||
low: 205413096,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 467608096,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "langfuse.observation.type",
|
||||
value: { stringValue: "span" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.tags",
|
||||
value: {
|
||||
arrayValue: { values: [{ stringValue: "tag1" }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "langfuse.trace.metadata.trace-key0",
|
||||
value: { stringValue: '"value1"' },
|
||||
},
|
||||
],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const events = langfuseOtelSpans.flatMap((span) =>
|
||||
convertOtelSpanToIngestionEvent(span, publicKey),
|
||||
);
|
||||
const traceEvents = events.filter((e) => e.type === "trace-create");
|
||||
const generationEvents = events.filter(
|
||||
(e) => e.type === "generation-create",
|
||||
);
|
||||
const spanEvents = events.filter((e) => e.type === "span-create");
|
||||
|
||||
expect(events.length).toBe(4);
|
||||
expect(traceEvents.length).toBe(2);
|
||||
expect(spanEvents.length).toBe(1);
|
||||
expect(generationEvents.length).toBe(1);
|
||||
|
||||
const spanEvent = spanEvents[0];
|
||||
const generationEvent = generationEvents[0];
|
||||
|
||||
const defaultMetadata = {
|
||||
resourceAttributes: {
|
||||
"langfuse.environment": "production",
|
||||
"langfuse.release": "0.0.1",
|
||||
"service.name": "unknown_service",
|
||||
"telemetry.sdk.language": "python",
|
||||
"telemetry.sdk.name": "opentelemetry",
|
||||
"telemetry.sdk.version": "1.32.0",
|
||||
},
|
||||
scope: {
|
||||
name: "langfuse-sdk",
|
||||
version: "2.60.3",
|
||||
},
|
||||
};
|
||||
|
||||
expect(spanEvent.body).toMatchObject({
|
||||
id: "dfe387fea7ef3b02",
|
||||
traceId: "2cce18f7e8cd065a0b4e634eef728391",
|
||||
parentObservationId: null,
|
||||
name: "my-span",
|
||||
startTime: "2025-04-30T15:28:50.424Z",
|
||||
endTime: "2025-04-30T15:28:50.687Z",
|
||||
environment: "production",
|
||||
metadata: { ...defaultMetadata },
|
||||
level: "DEFAULT",
|
||||
version: null,
|
||||
modelParameters: {},
|
||||
model: undefined,
|
||||
promptName: null,
|
||||
promptVersion: null,
|
||||
usageDetails: {},
|
||||
costDetails: {},
|
||||
input: null,
|
||||
output: null,
|
||||
});
|
||||
|
||||
expect(generationEvent.body).toMatchObject({
|
||||
id: "57f02554179741bd",
|
||||
traceId: "2cce18f7e8cd065a0b4e634eef728391",
|
||||
parentObservationId: "dfe387fea7ef3b02",
|
||||
name: "my-generation",
|
||||
startTime: "2025-04-30T15:28:50.686Z",
|
||||
endTime: "2025-04-30T15:28:50.686Z",
|
||||
environment: "production",
|
||||
metadata: { ...defaultMetadata },
|
||||
level: "WARNING",
|
||||
statusMessage: "nothing to report",
|
||||
version: "trace-0.0.1",
|
||||
modelParameters: {},
|
||||
model: "gpt-4o",
|
||||
promptName: "my-prompt",
|
||||
promptVersion: 1,
|
||||
usageDetails: { input_tokens: 123, output_tokens: 456 },
|
||||
costDetails: { input_tokens: 0.0001, output_tokens: 0.002 },
|
||||
input: '[{"role": "user", "content": "hello"}]',
|
||||
output: '{"role": "assistant", "content": "what\'s up?"}',
|
||||
});
|
||||
|
||||
expect(traceEvents[0].body).toMatchObject({
|
||||
id: "2cce18f7e8cd065a0b4e634eef728391",
|
||||
name: "test-trace",
|
||||
tags: ["tag2"],
|
||||
userId: "my-user",
|
||||
sessionId: "my-session",
|
||||
input: '[{"role": "user", "content": "hello"}]',
|
||||
output: '{"role": "assistant", "content": "what\'s up?"}',
|
||||
version: "trace-0.0.1",
|
||||
environment: "production",
|
||||
public: true,
|
||||
metadata: {
|
||||
...defaultMetadata,
|
||||
"trace-key1": '"value1"',
|
||||
"trace-key2": '"value2"',
|
||||
},
|
||||
});
|
||||
|
||||
expect(traceEvents[1].body).toMatchObject({
|
||||
id: "2cce18f7e8cd065a0b4e634eef728391",
|
||||
timestamp: "2025-04-30T15:28:50.424Z",
|
||||
name: "my-span",
|
||||
metadata: {
|
||||
...defaultMetadata,
|
||||
"trace-key0": '"value1"',
|
||||
},
|
||||
tags: ["tag1"],
|
||||
environment: "production",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a trace when as_root has been specified", () => {
|
||||
const langfuseOtelSpans = [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "telemetry.sdk.language",
|
||||
value: { stringValue: "python" },
|
||||
},
|
||||
{
|
||||
key: "telemetry.sdk.name",
|
||||
value: { stringValue: "opentelemetry" },
|
||||
},
|
||||
{
|
||||
key: "telemetry.sdk.version",
|
||||
value: { stringValue: "1.32.0" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.environment",
|
||||
value: { stringValue: "production" },
|
||||
},
|
||||
{ key: "langfuse.release", value: { stringValue: "0.0.1" } },
|
||||
{
|
||||
key: "service.name",
|
||||
value: { stringValue: "unknown_service" },
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "langfuse-sdk",
|
||||
version: "2.60.3",
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: "pk-lf-1234567890" },
|
||||
},
|
||||
],
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
149, 243, 185, 38, 199, 208, 9, 146, 91, 203, 93, 188, 39,
|
||||
49, 17, 32,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [212, 62, 55, 183, 209, 126, 84, 118],
|
||||
},
|
||||
parentSpanId: {
|
||||
type: "Buffer",
|
||||
data: [131, 78, 40, 181, 145, 127, 190, 246],
|
||||
},
|
||||
name: "my-span-with-custom-trace-id",
|
||||
kind: 1,
|
||||
startTimeUnixNano: {
|
||||
low: 1047784088,
|
||||
high: 406627672,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 1149405088,
|
||||
high: 406627672,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "langfuse.observation.type",
|
||||
value: { stringValue: "span" },
|
||||
},
|
||||
{
|
||||
key: "langfuse.internal.as_root",
|
||||
value: { boolValue: true },
|
||||
},
|
||||
],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const events = langfuseOtelSpans.flatMap((span) =>
|
||||
convertOtelSpanToIngestionEvent(span, publicKey),
|
||||
);
|
||||
const traceEvents = events.filter((e) => e.type === "trace-create");
|
||||
const spanEvents = events.filter((e) => e.type === "span-create");
|
||||
|
||||
expect(events.length).toBe(2);
|
||||
expect(traceEvents.length).toBe(1);
|
||||
expect(spanEvents.length).toBe(1);
|
||||
|
||||
const spanEvent = spanEvents[0];
|
||||
const traceEvent = traceEvents[0];
|
||||
|
||||
expect(spanEvent.body).toMatchObject({
|
||||
id: "d43e37b7d17e5476",
|
||||
traceId: "95f3b926c7d009925bcb5dbc27311120",
|
||||
parentObservationId: "834e28b5917fbef6",
|
||||
name: "my-span-with-custom-trace-id",
|
||||
startTime: "2025-05-05T13:42:33.936Z",
|
||||
endTime: "2025-05-05T13:42:34.038Z",
|
||||
environment: "production",
|
||||
level: "DEFAULT",
|
||||
});
|
||||
|
||||
expect(traceEvent.body).toMatchObject({
|
||||
id: "95f3b926c7d009925bcb5dbc27311120",
|
||||
timestamp: "2025-05-05T13:42:33.936Z",
|
||||
name: undefined,
|
||||
environment: "production",
|
||||
});
|
||||
});
|
||||
it("should throw an error if langfuse scope spans have wrong project ID", () => {
|
||||
const langfuseOtelSpans = [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "telemetry.sdk.language",
|
||||
value: {
|
||||
stringValue: "python",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "telemetry.sdk.name",
|
||||
value: {
|
||||
stringValue: "opentelemetry",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "telemetry.sdk.version",
|
||||
value: {
|
||||
stringValue: "1.32.0",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "service.name",
|
||||
value: {
|
||||
stringValue: "unknown_service",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "langfuse-sdk",
|
||||
version: "2.60.3",
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: {
|
||||
stringValue: "pk-lf-another",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
219, 242, 249, 255, 154, 168, 21, 165, 233, 52, 222, 186,
|
||||
28, 97, 54, 95,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [128, 191, 93, 22, 69, 180, 81, 135],
|
||||
},
|
||||
name: "t1",
|
||||
kind: 1,
|
||||
startTimeUnixNano: {
|
||||
low: -1422874264,
|
||||
high: 406650983,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: -904695264,
|
||||
high: 406650983,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "langfuse.observation.type",
|
||||
value: {
|
||||
stringValue: "span",
|
||||
},
|
||||
},
|
||||
],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
langfuseOtelSpans.flatMap((span) =>
|
||||
convertOtelSpanToIngestionEvent(span, publicKey),
|
||||
),
|
||||
).toThrowError("Langfuse OTEL SDK span has different public key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vendor Spans", () => {
|
||||
it("should convert an OpenLit OTel Span to Langfuse Events", () => {
|
||||
// Setup
|
||||
@@ -307,6 +859,149 @@ describe("OTel Resource Span Mapping", () => {
|
||||
);
|
||||
expect(parsedEvents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("LFE-5171: should convert a Semantic Kernel 1.55+ OTel Span with new event-based semantic conventions to Langfuse Events", () => {
|
||||
// Setup - Semantic Kernel 1.55+ uses new event names instead of deprecated gen_ai.content.prompt/completion
|
||||
const resourceSpan = {
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: { name: "Microsoft.SemanticKernel" },
|
||||
spans: [
|
||||
{
|
||||
traceId: {
|
||||
type: "Buffer",
|
||||
data: [
|
||||
234, 103, 55, 8, 68, 28, 41, 132, 165, 74, 62, 57, 98, 211,
|
||||
89, 95,
|
||||
],
|
||||
},
|
||||
spanId: {
|
||||
type: "Buffer",
|
||||
data: [185, 4, 191, 251, 32, 190, 109, 126],
|
||||
},
|
||||
name: "chat_completion",
|
||||
kind: 3,
|
||||
startTimeUnixNano: {
|
||||
low: 153687506,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 1327836088,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "gen_ai.response.model",
|
||||
value: { stringValue: "gpt-4-0613" },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
timeUnixNano: {
|
||||
low: 1327691067,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
name: "gen_ai.system.message",
|
||||
attributes: [
|
||||
{
|
||||
key: "content",
|
||||
value: {
|
||||
stringValue: "You are a helpful assistant.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timeUnixNano: {
|
||||
low: 1327691068,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
name: "gen_ai.user.message",
|
||||
attributes: [
|
||||
{
|
||||
key: "content",
|
||||
value: {
|
||||
stringValue: "What is the capital of France?",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
timeUnixNano: {
|
||||
low: 1327737136,
|
||||
high: 404677085,
|
||||
unsigned: true,
|
||||
},
|
||||
name: "gen_ai.choice",
|
||||
attributes: [
|
||||
{
|
||||
key: "index",
|
||||
value: {
|
||||
intValue: { low: 0, high: 0, unsigned: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "finish_reason",
|
||||
value: { stringValue: "stop" },
|
||||
},
|
||||
{
|
||||
key: "message",
|
||||
value: {
|
||||
stringValue: JSON.stringify({
|
||||
role: "assistant",
|
||||
content: "The capital of France is Paris.",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
status: { code: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
|
||||
|
||||
// Then
|
||||
// Will throw an error if the parsing fails
|
||||
const parsedEvents = langfuseEvents.map((event) =>
|
||||
ingestionEvent.parse(event),
|
||||
);
|
||||
expect(parsedEvents).toHaveLength(2);
|
||||
|
||||
// Check that input contains both system and user messages
|
||||
const observationEvent = parsedEvents.find(
|
||||
(event) => event.type === "generation-create",
|
||||
);
|
||||
expect(observationEvent?.body.input).toEqual([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "What is the capital of France?",
|
||||
},
|
||||
]);
|
||||
|
||||
// Check that output contains the choice
|
||||
expect(observationEvent?.body.output).toEqual({
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
message: JSON.stringify({
|
||||
role: "assistant",
|
||||
content: "The capital of France is Paris.",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property Mapping", () => {
|
||||
@@ -1149,6 +1844,180 @@ describe("OTel Resource Span Mapping", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
// Semantic Kernel 1.55+ new event-based semantic conventions
|
||||
[
|
||||
"should extract input from gen_ai.system.message event",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.system.message",
|
||||
otelEventAttributeKey: "content",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: "You are a helpful assistant.",
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract input from gen_ai.user.message event",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.user.message",
|
||||
otelEventAttributeKey: "content",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: "What is the capital of France?",
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: [
|
||||
{
|
||||
role: "user",
|
||||
content: "What is the capital of France?",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract input from gen_ai.assistant.message event with tool_calls",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.assistant.message",
|
||||
otelEventAttributeKey: "tool_calls",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: JSON.stringify([{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location": "Paris"}'
|
||||
}
|
||||
}]),
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: [
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: JSON.stringify([{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location": "Paris"}'
|
||||
}
|
||||
}]),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract input from gen_ai.tool.message event",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.tool.message",
|
||||
otelEventAttributeKey: "content",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: "Sunny, 22°C",
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: [
|
||||
{
|
||||
role: "tool",
|
||||
content: "Sunny, 22°C",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract output from gen_ai.choice event",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.choice",
|
||||
otelEventAttributeKey: "message",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: JSON.stringify({
|
||||
role: "assistant",
|
||||
content: "The capital of France is Paris.",
|
||||
}),
|
||||
},
|
||||
entityAttributeKey: "output",
|
||||
entityAttributeValue: {
|
||||
message: JSON.stringify({
|
||||
role: "assistant",
|
||||
content: "The capital of France is Paris.",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract output from gen_ai.choice event with finish_reason",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.choice",
|
||||
otelEventAttributeKey: "finish_reason",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: "stop",
|
||||
},
|
||||
entityAttributeKey: "output",
|
||||
entityAttributeValue: {
|
||||
finish_reason: "stop",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract input from gen_ai.tool.message event with id attribute",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.tool.message",
|
||||
otelEventAttributeKey: "id",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: "call_456",
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: [
|
||||
{
|
||||
role: "tool",
|
||||
id: "call_456",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract output from gen_ai.choice event with index attribute",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.choice",
|
||||
otelEventAttributeKey: "index",
|
||||
otelEventAttributeValue: {
|
||||
intValue: { low: 0, high: 0, unsigned: false },
|
||||
},
|
||||
entityAttributeKey: "output",
|
||||
entityAttributeValue: {
|
||||
index: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"should extract input from gen_ai.assistant.message event with content",
|
||||
{
|
||||
entity: "observation",
|
||||
otelEventName: "gen_ai.assistant.message",
|
||||
otelEventAttributeKey: "content",
|
||||
otelEventAttributeValue: {
|
||||
stringValue: "I'll help you with that.",
|
||||
},
|
||||
entityAttributeKey: "input",
|
||||
entityAttributeValue: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "I'll help you with that.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
])(
|
||||
"Events: %s",
|
||||
(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import chatCompletionHandler from "@/src/ee/features/playground/server/chatCompletionHandler";
|
||||
import chatCompletionHandler from "@/src/features/playground/server/chatCompletionHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 120;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user