Compare commits

...
35 Commits
Author SHA1 Message Date
steffen911 d905623cb3 chore: release v3.34.1 2025-02-28 11:11:43 +01:00
Steffen SchmitzandGitHub a87a3505b8 chore: add env.HOSTNAME support for worker container (#5779) 2025-02-28 10:06:41 +00:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
591de20a72 feat: simplify otel input/output and accept string token values (#5777)
* feat: simplify otel input/output and accept string token values

* chore: typo

* Update web/src/features/otel/server/index.ts

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

* chore: also beautify event attribute fallback

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-28 09:49:49 +00:00
Marc Klingen 33d3ab80cb chore: release v3.34.0 2025-02-27 22:54:18 +01:00
Marc KlingenandGitHub 6b9773caec feat(models): add openai gpt-4.5-preview (#5772) 2025-02-27 21:47:24 +00:00
Steffen SchmitzandGitHub c90b477d40 fix: disable tracing for resetPassword route (#5763) 2025-02-27 15:36:36 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
0f448ccdde fix: always return 422 for signup error handler (#5761)
* fix: always return 422 for signup error handler

* Update web/src/features/auth-credentials/server/signupApiHandler.ts

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

* Update web/src/features/auth-credentials/server/signupApiHandler.ts

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

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-27 15:33:41 +00:00
marliessophieandGitHub 74f93aad79 chore: move dashboard button to the right, unify eval headers (#5762)
chore: move dashboard button to the right
2025-02-27 15:15:40 +00:00
Max DeichmannandGitHub 9650e87f64 fix: standardize way to get sso blocked domains (#5760)
fix: standardise way to get sso blocked domains
2025-02-27 15:15:10 +00:00
Steffen SchmitzandGitHub c5fc30a9d0 feat: map otel environment config to langfuse environment (#5757) 2025-02-27 14:46:43 +00:00
steffen911 4a26f47971 chore: release v3.33.1 2025-02-27 15:34:24 +01:00
Steffen SchmitzGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
e3a9e61d07 perf: filter relevant observation for data set run item stats (#5752)
* perf: filter relevant observation for data set run item stats

* Update web/src/features/datasets/server/service.ts

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

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-27 14:08:07 +00:00
Hassieb Pakzad a12c2a3a97 fix(model-prices): bedrock claude 3-7 version 2025-02-27 13:14:05 +01:00
Hassieb PakzadandGitHub 80681e08af fix(model-prices): bedrock claude sonnet 3-7 version (#5753) 2025-02-27 13:10:08 +01:00
steffen911 4e97494390 chore: release v3.33.0 2025-02-27 11:58:10 +01:00
Steffen SchmitzandGitHub 346e5fe7b6 feat: map environment to score within eval service (#5749)
* feat: map environment to score within eval service

* chore: adjust tests

* chore: drop unused imports
2025-02-27 10:50:36 +00:00
Steffen SchmitzandGitHub 16482f588a fix: create traces via otel for empty parent span buffer (#5748)
* fix: create traces via otel for empty parent span buffer

* chore: test range for session duration
2025-02-27 10:10:51 +00:00
Marc KlingenandGitHub f047e21e13 chore: improved domain handling and early return in signupApiHandler (#5744) 2025-02-26 21:56:20 +00:00
marliessophieandGitHub 534e832c13 fix(ui): overlapping variables for chat prompt (#5740) 2025-02-26 17:47:03 +00:00
marliessophieandGitHub 5dbfec24f0 fix(ui): experiment from the prompt screen -> window does not fit screen (#5738) 2025-02-26 17:21:05 +00:00
Hassieb PakzadandGitHub 700705be84 chore(llm-keys): update bedrock form description (#5737) 2025-02-26 17:58:49 +01:00
Hassieb Pakzad 611c31ce54 chore: release v3.32.1 2025-02-26 17:06:23 +01:00
Hassieb PakzadandGitHub bdb5ddadd9 fix(ingestion): environment to immutable keys (#5736) 2025-02-26 16:01:26 +00:00
Hassieb PakzadandGitHub 0fd1f065bd fix(api): add environment to ingestion spec (#5735) 2025-02-26 16:02:49 +01:00
Marc KlingenandGitHub 8aa063303a chore(ui): move "configure tracing" to the right in dashboard header (#5731) 2025-02-26 13:59:21 +00:00
Marc KlingenandGitHub d0ff8a87fe chore(ui): minor improvements to onboarding pages (#5730)
chore: minor improvements to onboarding pages
2025-02-26 13:56:39 +00:00
marliessophieandGitHub 0e61ffd621 style(prompts): always show edit label button for prompt versions (#5729) 2025-02-26 13:41:01 +00:00
steffen911 9f20926bc4 chore: release v3.32.0 2025-02-26 14:36:38 +01:00
Steffen SchmitzandGitHub 81a232e4a4 fix: fallback to "default" for missing environment (#5728) 2025-02-26 13:34:33 +00:00
Marc KlingenandGitHub c7e7d62f47 feat(ui): add onboarding screens for core features (#5706)
* feat: add in-ui-onboarding for tracing

* feat(ui): add onboarding screens for core features

* add annotation queues

* move videos to static, add eval onboarding

* push

* push

* push

* push

* push
2025-02-26 13:13:43 +00:00
steffen911 2aa8971dff chore: release v3.31.0 2025-02-26 13:53:58 +01:00
Steffen SchmitzandGitHub 85f4cb0c13 fix: show retention settings for self-hosters (#5727) 2025-02-26 12:34:14 +00:00
Hassieb PakzadandGitHub 4bbba9d983 feat(api): add environment property to traces, scores, obs (#5724) 2025-02-26 13:32:03 +01:00
Steffen SchmitzandGitHub 0b7dd5a1a7 feat: process environment in ingestion pipeline (#5671)
* feat: process environment in ingestion pipeline

* chore: remove unnecessary status

* chore: typing

* chore: more typing

* chore: score env prop

* chore: adjust apis to pass tests

* chore: adjust ingestion service test

* chore: restrict allowed prefix
2025-02-26 12:18:38 +00:00
Steffen SchmitzandGitHub b5cad2693f fix: exclude clickhouse default date from session duration calc (#5726) 2025-02-26 09:51:52 +00:00
81 changed files with 1950 additions and 300 deletions
+9
View File
@@ -38,6 +38,9 @@ types:
public:
type: optional<boolean>
docs: Public traces are accessible via url without login
environment:
type: optional<string>
docs: The environment from which this trace originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'.
TraceWithDetails: # GET /traces
extends: Trace
properties:
@@ -145,6 +148,9 @@ types:
costDetails:
type: optional<map<string, double>>
docs: The cost details of the observation. Key is the name of the cost metric, value is the cost in USD. The total key is the sum of all (non-total) cost metrics or the total value ingested.
environment:
type: optional<string>
docs: The environment from which this observation originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'.
ObservationsView:
extends: Observation
@@ -249,6 +255,9 @@ types:
queueId:
type: optional<string>
docs: Reference an annotation queue on a score. Populated if the score was initially created in an annotation queue.
environment:
type: optional<string>
docs: The environment from which this score originated. Can be any lowercase alphanumeric string with hyphens and underscores that does not start with 'langfuse'.
NumericScore:
extends: BaseScore
properties:
@@ -168,6 +168,7 @@ types:
statusMessage: optional<string>
parentObservationId: optional<string>
version: optional<string>
environment: optional<string>
CreateEventBody:
extends: OptionalObservationBody
@@ -232,6 +233,7 @@ types:
level: optional<commons.ObservationLevel>
statusMessage: optional<string>
parentObservationId: optional<string>
environment: optional<string>
TraceBody:
properties:
@@ -246,6 +248,7 @@ types:
version: optional<string>
metadata: optional<unknown>
tags: optional<list<string>>
environment: optional<string>
public:
type: optional<boolean>
docs: Make trace publicly accessible via url
@@ -259,6 +262,7 @@ types:
id: optional<string>
traceId: string
name: string
environment: optional<string>
value:
type: commons.CreateScoreValue
docs: The value of the score. Must be passed as string for categorical scores, and numeric for boolean and numeric scores. Boolean score values must equal either 1 or 0 (true or false)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.30.0",
"version": "3.34.1",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -28,6 +28,7 @@ const BooleanData = z.object({
const CreateAnnotationScoreBase = z.object({
name: z.string(),
projectId: z.string(),
environment: z.string().default("default"),
traceId: z.string(),
configId: z.string().optional(),
observationId: z.string().optional(),
@@ -45,6 +45,7 @@ const ScoreBase = z.object({
id: z.string(),
timestamp: z.coerce.date(),
projectId: z.string(),
environment: z.string().default("default"),
name: z.string(),
source: z.enum(ScoreSource),
authorUserId: z.string().nullish(),
+12 -27
View File
@@ -118,6 +118,15 @@ export const UsageOrCostDetails = z
.union([OpenAIUsageSchema, RawUsageOrCostDetails])
.nullish();
export const EnvironmentName = z
.string()
.max(40, "Maximum length is 40 characters")
.regex(
/^(?!langfuse)[a-z0-9-_]+$/,
"Only alphanumeric lower case characters, hyphens, and underscores are allowed, and it must not start with 'langfuse'",
)
.default("default");
// Using z.any instead of jsonSchema for input/output as we saw huge CPU overhead for large numeric arrays.
// With this setup parsing should be more lightweight and doesn't block other requests.
// As we allow plain values, arrays, and objects the JSON parse via bodyParser should suffice.
@@ -130,6 +139,7 @@ export const TraceBody = z.object({
output: z.any().nullish(),
sessionId: z.string().nullish(),
userId: z.string().nullish(),
environment: EnvironmentName,
metadata: jsonSchema.nullish(),
release: z.string().nullish(),
version: z.string().nullish(),
@@ -139,6 +149,7 @@ export const TraceBody = z.object({
export const OptionalObservationBody = z.object({
traceId: z.string().nullish(),
environment: EnvironmentName,
name: z.string().nullish(),
startTime: stringDateTime,
metadata: jsonSchema.nullish(),
@@ -230,6 +241,7 @@ const BaseScoreBody = z.object({
id: z.string().nullish(),
name: NonEmptyString,
traceId: z.string(),
environment: EnvironmentName,
observationId: z.string().nullish(),
comment: z.string().nullish(),
source: z
@@ -383,18 +395,6 @@ export const SdkLogEvent = z.object({
id: z.string().nullish(), // Not used, but makes downstream processing easier.
});
// definitions for the ingestion API
export const observationTypes = [
"observation-create",
"observation-update",
"generation-create",
"generation-update",
"span-create",
"span-update",
"event-create",
];
export const eventTypes = {
TRACE_CREATE: "trace-create",
SCORE_CREATE: "score-create",
@@ -474,27 +474,12 @@ export const ingestionEvent = z.discriminatedUnion("type", [
export type IngestionEventType = z.infer<typeof ingestionEvent>;
export const ingestionBatchEvent = z.array(ingestionEvent);
export type IngestionBatchEventType = z.infer<typeof ingestionBatchEvent>;
export const ingestionEventWithProjectId = ingestionEvent.and(
z.object({ projectId: z.string() }),
);
export type IngestionEventWithProjectIdType = z.infer<
typeof ingestionEventWithProjectId
>;
export const ingestionApiSchema = z.object({
batch: ingestionBatchEvent,
metadata: jsonSchema.nullish(),
});
export const ingestionApiSchemaWithProjectId = ingestionApiSchema.extend({
projectId: z.string(),
});
export type IngestionApiSchemaWithProjectId = z.infer<
typeof ingestionApiSchemaWithProjectId
>;
export type ObservationEvent =
| z.infer<typeof legacyObservationCreateEvent>
| z.infer<typeof legacyObservationUpdateEvent>
+2
View File
@@ -101,6 +101,8 @@ export const openAIModels = [
"o1-preview-2024-09-12",
"o1-mini",
"o1-mini-2024-09-12",
"gpt-4.5-preview",
"gpt-4.5-preview-2025-02-27",
"gpt-4-turbo-preview",
"gpt-4-1106-preview",
"gpt-4-0613",
@@ -36,6 +36,7 @@ export const observationRecordBaseSchema = z.object({
project_id: z.string(),
type: z.string(),
parent_observation_id: z.string().nullish(),
environment: z.string().default("default"),
name: z.string().nullish(),
metadata: z.record(z.string()),
level: z.string().nullish(),
@@ -52,9 +53,6 @@ export const observationRecordBaseSchema = z.object({
prompt_version: z.number().nullish(),
is_deleted: z.number(),
});
export type ObservationRecordBaseType = z.infer<
typeof observationRecordBaseSchema
>;
export const observationRecordReadSchema = observationRecordBaseSchema.extend({
created_at: clickhouseStringDateSchema,
@@ -98,6 +96,7 @@ export const traceRecordBaseSchema = z.object({
release: z.string().nullish(),
version: z.string().nullish(),
project_id: z.string(),
environment: z.string().default("default"),
public: z.boolean(),
bookmarked: z.boolean(),
tags: z.array(z.string()),
@@ -106,7 +105,6 @@ export const traceRecordBaseSchema = z.object({
session_id: z.string().nullish(),
is_deleted: z.number(),
});
export type TraceRecordBaseType = z.infer<typeof traceRecordBaseSchema>;
export const traceRecordReadSchema = traceRecordBaseSchema.extend({
timestamp: clickhouseStringDateSchema,
@@ -129,6 +127,7 @@ export const scoreRecordBaseSchema = z.object({
project_id: z.string(),
trace_id: z.string(),
observation_id: z.string().nullish(),
environment: z.string().default("default"),
name: z.string(),
value: z.number().nullish(),
source: z.string(),
@@ -140,7 +139,6 @@ export const scoreRecordBaseSchema = z.object({
queue_id: z.string().nullish(),
is_deleted: z.number(),
});
export type ScoreRecordBaseType = z.infer<typeof scoreRecordBaseSchema>;
export const scoreRecordReadSchema = scoreRecordBaseSchema.extend({
created_at: clickhouseStringDateSchema,
@@ -246,6 +244,7 @@ export const convertPostgresTraceToInsert = (
: Array.isArray(trace.metadata)
? { metadata: trace.metadata }
: trace.metadata,
environment: trace.environment,
release: trace.release,
version: trace.version,
project_id: trace.project_id,
@@ -282,6 +281,7 @@ export const convertPostgresObservationToInsert = (
project_id: observation.project_id,
type: observation.type,
parent_observation_id: observation.parent_observation_id,
environment: observation.environment,
start_time: observation.start_time?.getTime(),
end_time: observation.end_time?.getTime(),
name: observation.name,
@@ -345,6 +345,7 @@ export const convertPostgresScoreToInsert = (
project_id: score.project_id,
trace_id: score.trace_id,
observation_id: score.observation_id,
environment: score.environment,
name: score.name,
value: score.value,
source: score.source,
@@ -114,6 +114,7 @@ export const getObservationsViewForTrace = async (
project_id,
type,
parent_observation_id,
environment,
start_time,
end_time,
name,
@@ -177,6 +178,7 @@ export const getObservationForTraceIdByName = async (
project_id,
type,
parent_observation_id,
environment,
start_time,
end_time,
name,
@@ -342,6 +344,7 @@ const getObservationByIdInternal = async (
id,
trace_id,
project_id,
environment,
type,
parent_observation_id,
start_time,
@@ -1136,6 +1139,7 @@ export const getObservationMetricsForPrompts = async (
export const getLatencyAndTotalCostForObservations = async (
projectId: string,
observationIds: string[],
timestamp?: Date,
) => {
const query = `
SELECT
@@ -1144,7 +1148,8 @@ export const getLatencyAndTotalCostForObservations = async (
dateDiff('millisecond', start_time, end_time) AS latency_ms
FROM observations FINAL
WHERE project_id = {projectId: String}
AND id IN ({observationIds: Array(String)})
AND id IN ({observationIds: Array(String)})
${timestamp ? `AND start_time >= {timestamp: DateTime64(3)}` : ""}
`;
const rows = await queryClickhouse<{
id: string;
@@ -1155,6 +1160,9 @@ export const getLatencyAndTotalCostForObservations = async (
params: {
projectId,
observationIds,
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
},
tags: {
feature: "tracing",
@@ -1174,6 +1182,7 @@ export const getLatencyAndTotalCostForObservations = async (
export const getLatencyAndTotalCostForObservationsByTraces = async (
projectId: string,
traceIds: string[],
timestamp?: Date,
) => {
const query = `
SELECT
@@ -1183,6 +1192,7 @@ export const getLatencyAndTotalCostForObservationsByTraces = async (
FROM observations FINAL
WHERE project_id = {projectId: String}
AND trace_id IN ({traceIds: Array(String)})
${timestamp ? `AND start_time >= {timestamp: DateTime64(3)}` : ""}
GROUP BY trace_id
`;
const rows = await queryClickhouse<{
@@ -1194,6 +1204,9 @@ export const getLatencyAndTotalCostForObservationsByTraces = async (
params: {
projectId,
traceIds,
...(timestamp
? { timestamp: convertDateToClickhouseDateTime(timestamp) }
: {}),
},
tags: {
feature: "tracing",
@@ -55,6 +55,7 @@ export const convertObservation = (
traceId: record.trace_id ?? null,
projectId: record.project_id,
type: record.type as ObservationType,
environment: record.environment,
parentObservationId: record.parent_observation_id ?? null,
startTime: parseClickhouseUTCDateTimeFormat(record.start_time),
endTime: record.end_time
@@ -372,6 +372,7 @@ export const getScoresUiTable = async (props: {
const rows = await getScoresUiGeneric<{
id: string;
project_id: string;
environment: string;
name: string;
value: number;
string_value: string | null;
@@ -396,6 +397,7 @@ export const getScoresUiTable = async (props: {
select: `
s.id,
s.project_id,
s.environment,
s.name,
s.value,
s.string_value,
@@ -424,6 +426,7 @@ export const getScoresUiTable = async (props: {
return rows.map((row) => ({
projectId: row.project_id,
environment: row.environment,
authorUserId: row.author_user_id,
traceId: row.trace_id,
observationId: row.observation_id,
@@ -875,3 +878,27 @@ export const getScoresForPostHog = async function* (
};
}
};
export const hasAnyScore = async (projectId: string) => {
const query = `
SELECT 1
FROM scores
WHERE project_id = {projectId: String}
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
projectId,
},
tags: {
feature: "tracing",
type: "score",
kind: "hasAny",
projectId,
},
});
return rows.length > 0;
};
@@ -17,6 +17,7 @@ export const convertToScore = (row: ScoreRecordReadType): Score => {
id: row.id,
timestamp: new Date(row.timestamp),
projectId: row.project_id,
environment: row.environment,
traceId: row.trace_id,
observationId: row.observation_id ?? null,
name: row.name,
@@ -59,3 +59,14 @@ export const getPublicSessionsFilter = async (
? [...filter.filter((f) => f.column !== "⭐️"), ...additionalBookmarkFilter]
: [...additionalBookmarkFilter];
};
export const hasAnySession = async (projectId: string) => {
const count = await prisma.traceSession.count({
where: {
projectId,
},
take: 1,
});
return count > 0;
};
@@ -196,13 +196,13 @@ export const getTracesBySessionId = async (
export const hasAnyTrace = async (projectId: string) => {
const query = `
SELECT count(*) as count
SELECT 1
FROM traces
WHERE project_id = {projectId: String}
LIMIT 1
`;
const rows = await queryClickhouse<{ count: string }>({
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
projectId,
@@ -210,12 +210,12 @@ export const hasAnyTrace = async (projectId: string) => {
tags: {
feature: "tracing",
type: "trace",
kind: "exists",
kind: "hasAny",
projectId,
},
});
return rows.length > 0 && Number(rows[0].count) > 0;
return rows.length > 0;
};
export const getTraceCountsByProjectInCreationInterval = async ({
@@ -562,6 +562,32 @@ export const deleteTracesByProjectId = async (projectId: string) => {
});
};
export const hasAnyUser = async (projectId: string) => {
const query = `
SELECT 1
FROM traces
WHERE project_id = {projectId: String}
AND user_id IS NOT NULL
AND user_id != ''
LIMIT 1
`;
const rows = await queryClickhouse<{ 1: number }>({
query,
params: {
projectId,
},
tags: {
feature: "tracing",
type: "user",
kind: "hasAny",
projectId,
},
});
return rows.length > 0;
};
export const getTotalUserCount = async (
projectId: string,
filter: FilterState,
@@ -14,6 +14,7 @@ export const convertTraceDomainToClickhouse = (
name: trace.name,
user_id: trace.userId,
metadata: trace.metadata as Record<string, string>,
environment: trace.environment,
release: trace.release,
version: trace.version,
project_id: trace.projectId,
@@ -38,6 +39,7 @@ export const convertClickhouseToDomain = (
projectId: record.project_id,
name: record.name ?? null,
timestamp: parseClickhouseUTCDateTimeFormat(record.timestamp),
environment: record.environment,
tags: record.tags,
bookmarked: record.bookmarked,
release: record.release ?? null,
@@ -31,6 +31,7 @@ export type Observation = {
id: string;
traceId: string | null;
projectId: string;
environment: string;
type: ObservationType;
startTime: Date;
endTime: Date | null;
@@ -69,6 +70,7 @@ export type ObservationView = {
type: ObservationType;
startTime: Date;
endTime: Date | null;
environment: string;
name: string | null;
metadata: Prisma.JsonValue | null;
parentObservationId: string | null;
@@ -104,6 +106,7 @@ export type Score = {
id: string;
timestamp: Date;
projectId: string;
environment: string;
name: string;
value: number | null;
source: ScoreSourceType;
@@ -124,6 +127,7 @@ export type Trace = {
timestamp: Date;
name: string | null;
userId: string | null;
environment: string;
metadata: Prisma.JsonValue | null;
release: string | null;
version: string | null;
@@ -271,7 +271,9 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
? `
,
sum(o.obs_count) as total_observations,
date_diff('millisecond', min(min_start_time), max(max_end_time)) as duration,
-- Use minIf, because ClickHouse fills 1970-01-01 on left joins. We assume that no
-- LLM session started on that date so this behaviour should yield better results.
date_diff('millisecond', minIf(min_start_time, min_start_time > '1970-01-01'), max(max_end_time)) as duration,
sumMap(o.sum_usage_details) as session_usage_details,
sumMap(o.sum_cost_details) as session_cost_details,
arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sumMap(o.sum_cost_details)))) as session_input_cost,
@@ -11,6 +11,7 @@ export const createTrace = (trace: Partial<TraceRecordInsertType>) => {
project_id: v4(),
session_id: v4(),
timestamp: Date.now(),
environment: "default",
metadata: {
source: "API",
server: "Node",
@@ -38,6 +39,7 @@ export const createObservation = (
trace_id: v4(),
project_id: v4(),
type: "GENERATION",
environment: "default",
metadata: {
source: "API",
server: "Node",
@@ -76,6 +78,7 @@ export const createScore = (score: Partial<ScoreRecordInsertType>) => {
project_id: v4(),
trace_id: v4(),
observation_id: v4(),
environment: "default",
name: "test-score" + v4(),
timestamp: Date.now(),
value: 100.5,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.30.0",
"version": "3.34.1",
"private": true,
"license": "MIT",
"engines": {
+33
View File
@@ -2628,6 +2628,13 @@ components:
type: boolean
nullable: true
description: Public traces are accessible via url without login
environment:
type: string
nullable: true
description: >-
The environment from which this trace originated. Can be any
lowercase alphanumeric string with hyphens and underscores that does
not start with 'langfuse'.
required:
- id
- timestamp
@@ -2819,6 +2826,13 @@ components:
The cost details of the observation. Key is the name of the cost
metric, value is the cost in USD. The total key is the sum of all
(non-total) cost metrics or the total value ingested.
environment:
type: string
nullable: true
description: >-
The environment from which this observation originated. Can be any
lowercase alphanumeric string with hyphens and underscores that does
not start with 'langfuse'.
required:
- id
- type
@@ -3033,6 +3047,13 @@ components:
description: >-
Reference an annotation queue on a score. Populated if the score was
initially created in an annotation queue.
environment:
type: string
nullable: true
description: >-
The environment from which this score originated. Can be any
lowercase alphanumeric string with hyphens and underscores that does
not start with 'langfuse'.
required:
- id
- traceId
@@ -3734,6 +3755,9 @@ components:
version:
type: string
nullable: true
environment:
type: string
nullable: true
CreateEventBody:
title: CreateEventBody
type: object
@@ -3901,6 +3925,9 @@ components:
parentObservationId:
type: string
nullable: true
environment:
type: string
nullable: true
required:
- type
TraceBody:
@@ -3940,6 +3967,9 @@ components:
items:
type: string
nullable: true
environment:
type: string
nullable: true
public:
type: boolean
nullable: true
@@ -3964,6 +3994,9 @@ components:
name:
type: string
example: novelty
environment:
type: string
nullable: true
value:
$ref: '#/components/schemas/CreateScoreValue'
description: >-
@@ -54,6 +54,21 @@ describe("/api/public/ingestion API Endpoint", () => {
},
},
],
[
"non-default-environment",
{
id: randomUUID(),
type: "trace-create",
timestamp: new Date().toISOString(),
body: {
id: randomUUID(),
timestamp: new Date().toISOString(),
metadata: { hello: "world" },
input: ["hello", { world: "world" }, [1, 2, 3]],
environment: "production",
},
},
],
])(
"should create traces via the ingestion API (%s)",
async (_name: string, entity: any) => {
@@ -71,6 +86,9 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(trace!.metadata).toEqual(entity.body?.metadata ?? {});
expect(trace!.input).toEqual(entity.body?.input ?? null);
expect(trace!.output).toEqual(entity.body?.output ?? null);
expect(trace!.environment).toEqual(
entity.body?.environment ?? "default",
);
});
},
);
@@ -124,6 +142,23 @@ describe("/api/public/ingestion API Endpoint", () => {
},
},
],
[
"span-non-default-environment",
"SPAN",
{
id: randomUUID(),
type: "span-create",
timestamp: new Date().toISOString(),
body: {
id: randomUUID(),
traceId: randomUUID(),
startTime: new Date().toISOString(),
input: ["hello", { world: "world" }, [1, 2, 3]],
output: ["hello", { world: [2, 3, "test"] }, [1, 2, 3]],
environment: "production",
},
},
],
])(
"should create observations via the ingestion API (%s)",
async (_name: string, type: string, entity: any) => {
@@ -146,6 +181,9 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(observation!.input).toEqual(entity.body?.input ?? null);
expect(observation!.output).toEqual(entity.body?.output ?? null);
expect(observation!.type).toBe(type);
expect(observation!.environment).toEqual(
entity.body?.environment ?? "default",
);
});
},
);
@@ -166,6 +204,22 @@ describe("/api/public/ingestion API Endpoint", () => {
},
},
],
[
"non-default-environment",
{
id: randomUUID(),
type: "score-create",
timestamp: new Date().toISOString(),
body: {
id: randomUUID(),
name: "score-name",
traceId: randomUUID(),
value: 100.5,
observationId: randomUUID(),
environment: "production",
},
},
],
])(
"should create scores via the ingestion API (%s)",
async (_name: string, entity: any) => {
@@ -181,6 +235,9 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(score!.id).toBe(entity.body.id);
expect(score!.projectId).toBe(projectId);
expect(score!.value).toEqual(100.5);
expect(score!.environment).toEqual(
entity.body?.environment ?? "default",
);
});
},
);
@@ -218,6 +275,36 @@ describe("/api/public/ingestion API Endpoint", () => {
expect(response.body.errors[0].message).toBe("Invalid request data");
});
it.each([
"langfuse-test",
".invalidcharacter!",
"incrediblylongstringwithmorethan40characters",
])(
"should fail for invalid environments (%s)",
async (environment: string) => {
const entity = {
id: randomUUID(),
type: "score-create",
timestamp: new Date().toISOString(),
body: {
id: randomUUID(),
name: "score-name",
traceId: randomUUID(),
value: 100.5,
observationId: randomUUID(),
environment,
},
};
const response = await makeAPICall("POST", "/api/public/ingestion", {
batch: [entity],
});
expect(response.status).toBe(207);
expect(response.body.errors[0].status).toBe(400);
},
);
// Disabled until eventLog becomes the default behaviour.
it("should create a log entry for the S3 file", async () => {
const traceId = v4();
@@ -322,4 +322,68 @@ describe("trpc.sessions", () => {
expect(Number(session2?.session_output_usage)).toBeGreaterThan(0);
expect(Number(session2?.session_total_usage)).toBeGreaterThan(0);
});
it("LFE-4113: should GET correct metrics for a list of sessions without observations", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const sessionId = v4();
await prisma.traceSession.createMany({
data: [
{
id: sessionId,
projectId: projectId,
},
],
});
const traces = [
createTrace({
session_id: sessionId,
project_id: projectId,
user_id: "user1",
}),
createTrace({
session_id: sessionId,
project_id: projectId,
user_id: "user3",
}),
];
await createTracesCh(traces);
// Only trace 2 has observations
const observations = [
createObservation({
trace_id: traces[1].id,
project_id: projectId,
start_time: new Date().getTime() - 1000,
}),
createObservation({
trace_id: traces[1].id,
project_id: projectId,
start_time: new Date().getTime(),
}),
];
await createObservationsCh(observations);
const sessions = await getSessionsWithMetrics({
projectId: projectId,
filter: [
{
column: "id",
type: "stringOptions",
operator: "any of",
value: [sessionId],
},
],
});
expect(sessions.length).toBe(1);
expect(sessions[0]).toBeDefined();
expect(sessions[0]?.trace_count).toBe(2);
expect(parseInt(sessions[0]?.duration as any)).toBeGreaterThan(995);
expect(parseInt(sessions[0]?.duration as any)).toBeLessThan(1005);
});
});
@@ -332,7 +332,75 @@ describe("OTel Resource Span Mapping", () => {
},
};
it("should interpret an empty buffer as an unset parentSpanId", async () => {
// https://github.com/langchain4j/langchain4j/issues/2328#issuecomment-2686129552
// Empty buffers where detected as truthy, i.e. behaved like they had a parent span.
// Setup
const resourceSpan = {
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
parentSpanId: {
type: "Buffer",
data: [],
},
},
],
},
],
};
// When
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
// Then
// Expect a span and a trace to be created
expect(langfuseEvents).toHaveLength(2);
});
it.each([
[
"should cast input_tokens from string to number",
{
entity: "observation",
otelAttributeKey: "gen_ai.usage.input_tokens",
otelAttributeValue: { stringValue: "15" },
entityAttributeKey: "usageDetails.input",
entityAttributeValue: 15,
},
],
[
"should extract environment on trace for langfuse.environment",
{
entity: "trace",
otelAttributeKey: "langfuse.environment",
otelAttributeValue: { stringValue: "test" },
entityAttributeKey: "environment",
entityAttributeValue: "test",
},
],
[
"should extract environment on observation for deployment.environment.name",
{
entity: "observation",
otelAttributeKey: "deployment.environment.name",
otelAttributeValue: { stringValue: "test" },
entityAttributeKey: "environment",
entityAttributeValue: "test",
},
],
[
"should fallback to default on observation if no environment present",
{
entity: "observation",
otelAttributeKey: "unused.key",
otelAttributeValue: { stringValue: "" },
entityAttributeKey: "environment",
entityAttributeValue: "default",
},
],
[
"should extract promptName on observation from langfuse.prompt.name",
{
@@ -705,6 +773,36 @@ describe("OTel Resource Span Mapping", () => {
entityAttributeValue: "1.0.5",
},
],
[
"should extract environment on trace for langfuse.environment",
{
entity: "trace",
otelResourceAttributeKey: "langfuse.environment",
otelResourceAttributeValue: { stringValue: "test" },
entityAttributeKey: "environment",
entityAttributeValue: "test",
},
],
[
"should extract environment on observation for deployment.environment.name",
{
entity: "observation",
otelResourceAttributeKey: "deployment.environment.name",
otelResourceAttributeValue: { stringValue: "test" },
entityAttributeKey: "environment",
entityAttributeValue: "test",
},
],
[
"should fallback to default on observation if no environment present",
{
entity: "observation",
otelResourceAttributeKey: "unused.key",
otelResourceAttributeValue: { stringValue: "" },
entityAttributeKey: "environment",
entityAttributeValue: "default",
},
],
])(
"ResourceAttributes: %s",
(
@@ -745,5 +843,125 @@ describe("OTel Resource Span Mapping", () => {
);
},
);
it.each([
[
"should extract input on trace from event attributes",
{
entity: "trace",
otelEventName: "gen_ai.content.prompt",
otelEventAttributeKey: "gen_ai.prompt",
otelEventAttributeValue: {
stringValue: "user: What is LLM Observability?",
},
entityAttributeKey: "input",
entityAttributeValue: "user: What is LLM Observability?",
},
],
[
"should extract array input on trace event attributes",
{
entity: "trace",
otelEventName: "gen_ai.content.prompt",
otelEventAttributeKey: "gen_ai.prompt",
otelEventAttributeValue: {
arrayValue: {
values: [
{
stringValue: "Reply with the word 'java'",
},
],
},
},
entityAttributeKey: "input",
entityAttributeValue: ["Reply with the word 'java'"],
},
],
[
"should extract output on observation from event attributes",
{
entity: "observation",
otelEventName: "gen_ai.content.completion",
otelEventAttributeKey: "gen_ai.completion",
otelEventAttributeValue: {
stringValue:
"assistant: LLM Observability stands for logs, metrics, and traces observability.",
},
entityAttributeKey: "output",
entityAttributeValue:
"assistant: LLM Observability stands for logs, metrics, and traces observability.",
},
],
[
"should extract output on observation from event attributes even if no gen_ai.completion attribute is available",
{
entity: "observation",
otelEventName: "gen_ai.content.completion",
otelEventAttributeKey: "gen_ai.something_else",
otelEventAttributeValue: {
stringValue:
"assistant: LLM Observability stands for logs, metrics, and traces observability.",
},
entityAttributeKey: "output",
entityAttributeValue: {
"gen_ai.something_else":
"assistant: LLM Observability stands for logs, metrics, and traces observability.",
},
},
],
])(
"Events: %s",
(
_name: string,
spec: {
entity: string;
otelEventName: string;
otelEventAttributeKey: string;
otelEventAttributeValue: any;
entityAttributeKey: string;
entityAttributeValue: any;
},
) => {
// Setup
const resourceSpan = {
resource: {},
scopeSpans: [
{
spans: [
{
...defaultSpanProps,
events: [
{
timeUnixNano: {
low: 1327691067,
high: 404677085,
unsigned: true,
},
name: spec.otelEventName,
attributes: [
{
key: spec.otelEventAttributeKey,
value: spec.otelEventAttributeValue,
},
],
},
],
},
],
},
],
};
// When
const langfuseEvents = convertOtelSpanToIngestionEvent(resourceSpan);
// Then
const entity: { body: Record<string, any> } =
spec.entity === "trace" ? langfuseEvents[0] : langfuseEvents[1];
expect(entity.body[spec.entityAttributeKey]).toEqual(
spec.entityAttributeValue,
);
},
);
});
});
@@ -0,0 +1,63 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { ClipboardCheck, Users, BarChart4, GitMerge } from "lucide-react";
import { CreateOrEditAnnotationQueueButton } from "@/src/ee/features/annotation-queues/components/CreateOrEditAnnotationQueueButton";
export function AnnotationQueuesOnboarding({
projectId,
}: {
projectId: string;
}) {
const valuePropositions: ValueProposition[] = [
{
title: "Manage scoring workflows",
description:
"Create and manage annotation queues to streamline your scoring workflows",
icon: <ClipboardCheck className="h-4 w-4" />,
},
{
title: "Collaborate with annotators",
description:
"Invite team members to annotate and evaluate your LLM outputs",
icon: <Users className="h-4 w-4" />,
},
{
title: "Track annotation metrics",
description:
"Monitor annotation progress and quality metrics across your team",
icon: <BarChart4 className="h-4 w-4" />,
},
{
title: "Baseline evaluation efforts",
description:
"Use annotation data as a baseline to evaluate your other evaluation metrics",
icon: <GitMerge className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with Annotation Queues"
description="Annotation queues help you manage manual annotation/labeling for your LLM projects. Create queues, define annotation metrics, and track progress."
valuePropositions={valuePropositions}
primaryAction={{
label: "Create Annotation Queue",
component: (
<CreateOrEditAnnotationQueueButton
variant="default"
projectId={projectId}
size="lg"
/>
),
}}
secondaryAction={{
label: "Learn More",
href: "https://langfuse.com/docs/scores/annotation",
}}
videoSrc="https://static.langfuse.com/prod-assets/onboarding/annotation-queue-overview-v1.mp4"
/>
);
}
@@ -0,0 +1,58 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { Database, Beaker, Zap, Code } from "lucide-react";
import { DatasetActionButton } from "@/src/features/datasets/components/DatasetActionButton";
export function DatasetsOnboarding({ projectId }: { projectId: string }) {
const valuePropositions: ValueProposition[] = [
{
title: "Continuous improvement",
description:
"Create datasets from production edge cases to improve your application",
icon: <Zap className="h-4 w-4" />,
},
{
title: "Pre-deployment testing",
description: "Benchmark new releases before deploying to production",
icon: <Beaker className="h-4 w-4" />,
},
{
title: "Structured testing",
description:
"Run experiments on collections of inputs and expected outputs",
icon: <Database className="h-4 w-4" />,
},
{
title: "Custom workflows",
description:
"Build custom workflows around your datasets via the API and SDKs, e.g. for fine-tuning, few-shotting",
icon: <Code className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with Datasets"
description="Datasets in Langfuse are collections of inputs (and expected outputs) for your LLM application. You can for example use them to benchmark new releases before deployment to production."
valuePropositions={valuePropositions}
primaryAction={{
label: "Create Dataset",
component: (
<DatasetActionButton
variant="default"
mode="create"
projectId={projectId}
size="lg"
/>
),
}}
secondaryAction={{
label: "Learn More",
href: "https://langfuse.com/docs/datasets",
}}
/>
);
}
@@ -0,0 +1,56 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { Bot, Gauge, Zap, BarChart4 } from "lucide-react";
interface EvaluatorsOnboardingProps {
projectId: string;
}
export function EvaluatorsOnboarding({ projectId }: EvaluatorsOnboardingProps) {
const valuePropositions: ValueProposition[] = [
{
title: "Automate evaluations",
description:
"Use LLM-as-a-judge to automatically evaluate your traces without manual review",
icon: <Bot className="h-4 w-4" />,
},
{
title: "Measure quality",
description:
"Create custom evaluation criteria to measure the quality of your LLM outputs",
icon: <Gauge className="h-4 w-4" />,
},
{
title: "Scale efficiently",
description:
"Evaluate thousands of traces automatically with customizable sampling rates",
icon: <Zap className="h-4 w-4" />,
},
{
title: "Track performance",
description:
"Monitor evaluation metrics over time to identify trends and improvements",
icon: <BarChart4 className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with LLM-as-a-Judge Evaluations"
description="Create evaluation templates and evaluators to automatically score your traces with LLM-as-a-judge. Set up custom evaluation criteria and let AI help you measure the quality of your outputs."
valuePropositions={valuePropositions}
primaryAction={{
label: "Create Evaluator",
href: `/project/${projectId}/evals/new`,
}}
secondaryAction={{
label: "Learn More",
href: "https://langfuse.com/docs/scores/model-based-evals",
}}
videoSrc="https://static.langfuse.com/prod-assets/onboarding/scores-llm-as-a-judge-overview-v1.mp4"
/>
);
}
@@ -0,0 +1,51 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { FileText, GitBranch, Zap, BarChart4 } from "lucide-react";
export function PromptsOnboarding({ projectId }: { projectId: string }) {
const valuePropositions: ValueProposition[] = [
{
title: "Decoupled from code",
description:
"Deploy new prompts without application redeployment, making updates faster and easier",
icon: <FileText className="h-4 w-4" />,
},
{
title: "Edit in UI or programmatically",
description:
"Non-technical users can easily edit prompts in the UI. Developers can optionally update prompts programmatically via the API and SDKs",
icon: <GitBranch className="h-4 w-4" />,
},
{
title: "Performance optimized",
description:
"Client-side caching prevents latency or availability issues for your applications",
icon: <Zap className="h-4 w-4" />,
},
{
title: "Compare metrics",
description:
"Track latency, cost, and evaluation metrics across different prompt versions",
icon: <BarChart4 className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with Prompt Management"
description="Langfuse Prompt Management helps you centrally manage, version control, and collaboratively iterate on your prompts. Start using prompt management to improve your LLM application's performance and maintainability."
valuePropositions={valuePropositions}
primaryAction={{
label: "Create Prompt",
href: `/project/${projectId}/prompts/new`,
}}
secondaryAction={{
label: "Learn More",
href: "https://langfuse.com/docs/prompts",
}}
/>
);
}
@@ -0,0 +1,48 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { ThumbsUp, Star, LineChart, Code } from "lucide-react";
export function ScoresOnboarding() {
const valuePropositions: ValueProposition[] = [
{
title: "Collect user feedback",
description:
"Gather thumbs up/down feedback from users to identify high and low quality outputs",
icon: <ThumbsUp className="h-4 w-4" />,
},
{
title: "Run model-based evaluations",
description:
"Use LLMs to automatically evaluate your application's outputs",
icon: <Star className="h-4 w-4" />,
},
{
title: "Track quality metrics",
description:
"Monitor quality metrics over time to identify trends and issues",
icon: <LineChart className="h-4 w-4" />,
},
{
title: "Use custom metrics",
description:
"Langfuse's scores are flexible and can be used to track any metric that's associated with an LLM application",
icon: <Code className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with Scores"
description="Scores allow you to evaluate the quality/safety of your LLM application through user feedback, model-based evaluations, or manual review. Scores can be used programmatically via the API and SDKs to track custom metrics."
valuePropositions={valuePropositions}
secondaryAction={{
label: "Learn More",
href: "https://langfuse.com/docs/scores",
}}
videoSrc="https://static.langfuse.com/prod-assets/onboarding/scores-overview-v1.mp4"
/>
);
}
@@ -0,0 +1,56 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { BarChart4, GitMerge, MessageSquare, Users } from "lucide-react";
import Link from "next/link";
export function SessionsOnboarding() {
const valuePropositions: ValueProposition[] = [
{
title: "Group related traces",
description:
"Sessions allow you to group related traces, such as a conversation or thread, for better organization and analysis",
icon: <MessageSquare className="h-4 w-4" />,
},
{
title: "Track user interactions",
description: "Monitor how users interact with your application over time",
icon: <Users className="h-4 w-4" />,
},
{
title: "Analyze conversation flows",
description: "Understand the complete flow of multi-turn conversations",
icon: <GitMerge className="h-4 w-4" />,
},
{
title: "Session-level metrics",
description:
"Get aggregated metrics for entire sessions, including costs and token usage",
icon: <BarChart4 className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with Sessions"
description="Sessions allow you to group related traces together, such as a conversation or thread. Use sessions to track interactions over time and analyze conversation/thread flows."
valuePropositions={valuePropositions}
gettingStarted={
<span>
To start using sessions, you need to add a `sessionId` to your traces.
See{" "}
<Link
href="https://langfuse.com/docs/tracing-features/sessions"
className="underline"
>
documentation
</Link>{" "}
for more details.
</span>
}
videoSrc="https://static.langfuse.com/prod-assets/onboarding/sessions-overview-v1.mp4"
/>
);
}
@@ -0,0 +1,57 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { setupTracingRoute } from "@/src/features/setup/setupRoutes";
import { BarChart4, GitMerge, Search, Zap } from "lucide-react";
interface TracesOnboardingProps {
projectId: string;
}
export function TracesOnboarding({ projectId }: TracesOnboardingProps) {
const valuePropositions: ValueProposition[] = [
{
title: "Full context capture",
description:
"Track the complete execution flow including API calls, context, prompts, parallelism and more",
icon: <GitMerge className="h-4 w-4" />,
},
{
title: "Cost monitoring",
description: "Track model usage and costs across your application",
icon: <BarChart4 className="h-4 w-4" />,
},
{
title: "Basis for evaluation",
description:
"Add evaluation scores to identify issues and track metrics over time",
icon: <Search className="h-4 w-4" />,
},
{
title: "Open and Multi-modal",
description:
"Langfuse traces can include images, audio, and other modalities. You can fully customize them to fit your needs",
icon: <Zap className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with LLM Tracing"
description="Traces allow you to track every LLM call and other relevant logic in your app/agent. Nested traces in Langfuse help to understand what is happening and identify the root cause of problems."
valuePropositions={valuePropositions}
primaryAction={{
label: "Configure Tracing",
href: setupTracingRoute(projectId),
}}
secondaryAction={{
label: "View Documentation",
href: "https://langfuse.com/docs/tracing",
}}
videoSrc="https://static.langfuse.com/prod-assets/onboarding/tracing-overview-v1.mp4"
className="bg-background dark:bg-background dark:text-white"
/>
);
}
@@ -0,0 +1,58 @@
import React from "react";
import {
SplashScreen,
type ValueProposition,
} from "@/src/components/ui/splash-screen";
import { Users, LineChart, Filter, BarChart4 } from "lucide-react";
import Link from "next/link";
export function UsersOnboarding() {
const valuePropositions: ValueProposition[] = [
{
title: "Track user interactions",
description:
"Attribute data in Langfuse to specific users by adding a userId to your traces",
icon: <Users className="h-4 w-4" />,
},
{
title: "Analyze user behavior",
description:
"Understand how different users interact with your LLM applications",
icon: <LineChart className="h-4 w-4" />,
},
{
title: "Filter by user segments",
description:
"Compare performance across different user segments to identify patterns",
icon: <Filter className="h-4 w-4" />,
},
{
title: "Monitor usage metrics",
description:
"Track token usage, costs, and other metrics on a per-user basis",
icon: <BarChart4 className="h-4 w-4" />,
},
];
return (
<SplashScreen
title="Get Started with User Tracking"
description="Correlate costs, evaluations and other LLM Application metrics to specific users. Start tracking users to better understand how they interact with your LLM applications."
valuePropositions={valuePropositions}
gettingStarted={
<span>
To start tracking users, you need to add a `userId` to your traces.
See{" "}
<Link
href="https://langfuse.com/docs/tracing-features/users"
className="underline"
>
documentation
</Link>{" "}
for more details.
</span>
}
videoSrc="https://static.langfuse.com/prod-assets/onboarding/users-overview-v1.mp4"
className="bg-background dark:bg-background dark:text-white"
/>
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/src/utils/tailwind";
const alertVariants = cva(
"relative w-full rounded-lg border p-3 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
"relative w-full rounded-lg border p-3 [&>svg~*]:pl-6 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-3 [&>svg]:top-3 [&>svg]:text-foreground",
{
variants: {
variant: {
+153
View File
@@ -0,0 +1,153 @@
import React, { useState } from "react";
import { cn } from "@/src/utils/tailwind";
import Image from "next/image";
import { InfoIcon } from "lucide-react";
import { ActionButton } from "@/src/components/ActionButton";
import { Alert, AlertTitle, AlertDescription } from "@/src/components/ui/alert";
export interface ValueProposition {
title: string;
description: string;
icon?: React.ReactNode;
}
export interface ActionConfig {
label: string;
href?: string;
onClick?: () => void;
component?: React.ReactNode;
}
export interface SplashScreenProps {
title: string;
description: string;
image?: {
src: string;
alt: string;
width: number;
height: number;
};
videoSrc?: string;
valuePropositions?: ValueProposition[];
primaryAction?: ActionConfig;
secondaryAction?: ActionConfig;
gettingStarted?: string | React.ReactNode;
className?: string;
}
interface VideoPlayerProps {
videoSrc: string;
}
function VideoPlayer({ videoSrc }: VideoPlayerProps) {
const [hasError, setHasError] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
return (
<div
className={cn(
"my-6 w-full max-w-3xl overflow-hidden rounded-lg border border-border",
{
hidden: !isLoaded || hasError,
},
)}
>
<video
src={videoSrc}
controls
autoPlay
muted
loop
controlsList="nodownload"
className="w-full"
onError={() => setHasError(true)}
onLoadedData={() => setIsLoaded(true)}
/>
</div>
);
}
export function SplashScreen({
title,
description,
image,
videoSrc,
valuePropositions = [],
primaryAction,
secondaryAction,
gettingStarted,
className,
}: SplashScreenProps) {
return (
<div
className={cn(
"mx-auto flex max-w-4xl flex-col items-center p-8",
className,
)}
>
<div className="mb-6 text-center">
<h2 className="mb-2 text-2xl font-bold">{title}</h2>
<p className="text-muted-foreground">{description}</p>
</div>
<div className="mb-8 flex w-full flex-wrap justify-center gap-4">
{primaryAction &&
(primaryAction.component || (
<ActionButton
size="lg"
onClick={primaryAction.onClick}
href={primaryAction.href}
>
{primaryAction.label}
</ActionButton>
))}
{secondaryAction &&
(secondaryAction.component || (
<ActionButton
variant="outline"
size="lg"
onClick={secondaryAction.onClick}
href={secondaryAction.href}
>
{secondaryAction.label}
</ActionButton>
))}
</div>
{gettingStarted && (
<Alert className="w-full max-w-3xl">
<InfoIcon className="mr-2 h-4 w-4" />
<AlertTitle>Getting Started</AlertTitle>
<AlertDescription>{gettingStarted}</AlertDescription>
</Alert>
)}
{videoSrc && <VideoPlayer videoSrc={videoSrc} />}
{!videoSrc && image && (
<div className="my-6 w-full max-w-3xl">
<Image
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
className="rounded-md"
/>
</div>
)}
{valuePropositions.length > 0 && (
<div className="my-6 grid w-full max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
{valuePropositions.map((prop, index) => (
<Alert key={index}>
{prop.icon}
<AlertTitle>{prop.title}</AlertTitle>
<AlertDescription>{prop.description}</AlertDescription>
</Alert>
))}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.30.0";
export const VERSION = "v3.34.1";
@@ -1,4 +1,4 @@
import { Button } from "@/src/components/ui/button";
import { Button, type ButtonProps } from "@/src/components/ui/button";
import React, { useEffect, useState } from "react";
import {
Dialog,
@@ -44,10 +44,12 @@ export const CreateOrEditAnnotationQueueButton = ({
projectId,
queueId,
variant = "secondary",
size,
}: {
projectId: string;
queueId?: string;
variant?: "secondary" | "ghost";
variant?: ButtonProps["variant"];
size?: ButtonProps["size"];
}) => {
const [isOpen, setIsOpen] = useState(false);
const hasAccess = useHasProjectAccess({
@@ -168,6 +170,7 @@ export const CreateOrEditAnnotationQueueButton = ({
hasEntitlement={hasEntitlement}
limitValue={queueCountData.data}
limit={queueLimit}
size={size}
>
<span className="ml-1 text-sm font-normal">
{queueId ? "Edit" : "New queue"}
@@ -4,6 +4,8 @@ import { useHasProjectAccess } from "@/src/features/rbac/utils/checkProjectAcces
import { useHasEntitlement } from "@/src/features/entitlements/hooks";
import { SupportOrUpgradePage } from "@/src/ee/features/billing/components/SupportOrUpgradePage";
import Page from "@/src/components/layouts/page";
import { AnnotationQueuesOnboarding } from "@/src/components/onboarding/AnnotationQueuesOnboarding";
import { api } from "@/src/utils/api";
export default function AnnotationQueues() {
const router = useRouter();
@@ -13,6 +15,23 @@ export default function AnnotationQueues() {
scope: "annotationQueues:read",
});
const hasEntitlement = useHasEntitlement("annotation-queues");
// Check if the user has any annotation queues
const { data: hasAnyQueue, isLoading } = api.annotationQueues.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId && hasEntitlement,
trpc: {
context: {
skipBatch: true,
},
},
refetchInterval: 10_000,
},
);
const showOnboarding = !isLoading && !hasAnyQueue;
if (!hasAccess || !hasEntitlement) return <SupportOrUpgradePage />;
return (
@@ -25,8 +44,14 @@ export default function AnnotationQueues() {
href: "https://langfuse.com/docs/scores/annotation",
},
}}
scrollable={showOnboarding}
>
<AnnotationQueuesTable projectId={projectId} />
{/* Show onboarding screen if user has no annotation queues */}
{showOnboarding ? (
<AnnotationQueuesOnboarding projectId={projectId} />
) : (
<AnnotationQueuesTable projectId={projectId} />
)}
</Page>
);
}
@@ -19,6 +19,35 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const queueRouter = createTRPCRouter({
hasAny: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
throwIfNoEntitlement({
entitlement: "annotation-queues",
projectId: input.projectId,
sessionUser: ctx.session.user,
});
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "annotationQueues:read",
});
const queue = await ctx.prisma.annotationQueue.findFirst({
where: {
projectId: input.projectId,
},
select: { id: true },
take: 1,
});
return queue !== null;
}),
all: protectedProjectProcedure
.input(
z.object({
@@ -1,9 +1,22 @@
// TODO: replace placeholder div with actual component
import { AlertCircle } from "lucide-react";
import { Alert, AlertTitle, AlertDescription } from "@/src/components/ui/alert";
export const SupportOrUpgradePage = () => {
return (
<div>
You have no access to this feature. Check with your system administrator
to get elevated access or upgrade your plan.
<div className="flex h-full w-full items-center justify-center p-6">
<div className="w-full max-w-md">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Access Restricted</AlertTitle>
<AlertDescription>
<p className="mb-2">This feature requires additional permissions</p>
<p>
Contact your system/project administrator for access or upgrade
your plan. Need help? Reach out to support.
</p>
</AlertDescription>
</Alert>
</div>
</div>
);
};
+47 -15
View File
@@ -12,27 +12,22 @@ import {
} from "@/src/components/ui/tabs-bar";
import { ActionButton } from "@/src/components/ActionButton";
import { api } from "@/src/utils/api";
import { useEntitlementLimit } from "@/src/features/entitlements/hooks";
import {
useEntitlementLimit,
useHasEntitlement,
} from "@/src/features/entitlements/hooks";
import { SupportOrUpgradePage } from "@/src/ee/features/billing/components/SupportOrUpgradePage";
import { EvaluatorsOnboarding } from "@/src/components/onboarding/EvaluatorsOnboarding";
export default function EvaluatorsPage() {
const router = useRouter();
const projectId = router.query.projectId as string;
const capture = usePostHogClientCapture();
// only fetched first page to get count of active evaluators
// ok as this includes the first 50 active evaluators
const evaluatorsFirstPage = api.evals.allConfigs.useQuery({
projectId,
page: 0,
limit: 50,
});
const evaluatorCountFirstPage = evaluatorsFirstPage.data?.configs.filter(
(e) => e.status === "ACTIVE",
).length;
const evaluatorLimit = useEntitlementLimit(
"model-based-evaluations-count-evaluators",
);
const hasEntitlement = useHasEntitlement("model-based-evaluations");
const hasWriteAccess = useHasProjectAccess({
projectId,
scope: "evalJob:CUD",
@@ -43,8 +38,45 @@ export default function EvaluatorsPage() {
scope: "evalJob:read",
});
if (!hasReadAccess) {
return null;
// Fetch counts of evaluator configs and templates
const countsQuery = api.evals.counts.useQuery(
{
projectId,
},
{
enabled: !!projectId && hasEntitlement,
trpc: {
context: {
skipBatch: true,
},
},
},
);
const showOnboarding =
countsQuery.data?.configCount === 0 &&
countsQuery.data?.templateCount === 0;
if (!hasReadAccess || !hasEntitlement) {
return <SupportOrUpgradePage />;
}
if (showOnboarding) {
return (
<Page
headerProps={{
title: "Evaluators",
help: {
description:
"Use LLM-as-a-judge evaluators as practical addition to human annotation. Configure an evaluation prompt and a model as judge to evaluate incoming traces.",
href: "https://langfuse.com/docs/scores/model-based-evals",
},
}}
scrollable
>
<EvaluatorsOnboarding projectId={projectId} />
</Page>
);
}
return (
@@ -78,7 +110,7 @@ export default function EvaluatorsPage() {
variant="outline"
onClick={() => capture("eval_config:new_form_open")}
href={`/project/${projectId}/evals/new`}
limitValue={evaluatorCountFirstPage ?? 0}
limitValue={countsQuery.data?.configCount ?? 0}
limit={evaluatorLimit}
>
New evaluator
@@ -33,7 +33,7 @@ export default function TemplatesPage() {
return (
<Page
headerProps={{
title: "Eval Templates",
title: "Evaluators",
help: {
description:
"Create an evaluation template. Choose from one of the pre-defined templates or create your own.",
@@ -134,6 +134,40 @@ export const evalRouter = createTRPCRouter({
});
return env.LANGFUSE_MAX_HISTORIC_EVAL_CREATION_LIMIT;
}),
counts: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
throwIfNoEntitlement({
entitlement: "model-based-evaluations",
projectId: input.projectId,
sessionUser: ctx.session.user,
});
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "evalJob:read",
});
const [configCount, templateCount] = await Promise.all([
ctx.prisma.jobConfiguration.count({
where: {
projectId: input.projectId,
jobType: "EVAL",
},
}),
ctx.prisma.evalTemplate.count({
where: {
projectId: input.projectId,
},
}),
]);
return {
configCount,
templateCount,
};
}),
allConfigs: protectedProjectProcedure
.input(
z.object({
@@ -1,12 +1,15 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/src/server/api/trpc";
import {
createTRPCRouter,
protectedProcedureWithoutTracing,
} from "@/src/server/api/trpc";
import { updateUserPassword } from "@/src/features/auth-credentials/lib/credentialsServerUtils";
import { TRPCError } from "@trpc/server";
import { isEmailVerifiedWithinCutoff } from "@/src/features/auth-credentials/lib/credentialsUtils";
import { passwordSchema } from "@/src/features/auth/lib/signupSchema";
export const credentialsRouter = createTRPCRouter({
resetPassword: protectedProcedure
resetPassword: protectedProcedureWithoutTracing
.input(
z.object({
password: passwordSchema,
@@ -5,6 +5,14 @@ import { getSsoAuthProviderIdForDomain } from "@/src/ee/features/multi-tenant-ss
import type { NextApiRequest, NextApiResponse } from "next";
import { logger } from "@langfuse/shared/src/server";
export function getSSOBlockedDomains() {
return (
env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT?.split(",")
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean) ?? []
);
}
/*
* Sign-up endpoint (email/password users), creates user in database.
* SSO users are created by the NextAuth adapters.
@@ -41,8 +49,7 @@ export async function signupApiHandler(
const body = validBody.data;
// check if email domain is blocked from email/password sign up via env
const blockedDomains =
env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT?.split(",") ?? [];
const blockedDomains = getSSOBlockedDomains();
const domain = body.email.split("@")[1]?.toLowerCase();
if (domain && blockedDomains.includes(domain)) {
res.status(422).json({
@@ -58,6 +65,7 @@ export async function signupApiHandler(
res.status(422).json({
message: "You must sign in via SSO for this domain.",
});
return;
}
// create the user
@@ -69,16 +77,12 @@ export async function signupApiHandler(
body.name,
);
} catch (error) {
if (error instanceof Error) {
logger.warn(
"Signup: Error creating user",
error.message,
body.email.toLowerCase(),
body.name,
error,
);
res.status(422).json({ message: error.message });
}
const message =
"Signup: Error creating user: " +
(error instanceof Error ? error.message : JSON.stringify(error));
logger.warn(message, body.email.toLowerCase(), body.name);
res.status(422).json({ message: message });
return;
}
@@ -1,4 +1,4 @@
import { Button } from "@/src/components/ui/button";
import { Button, type ButtonProps } from "@/src/components/ui/button";
import { Edit, LockIcon, PlusIcon, Trash } from "lucide-react";
import {
Dialog,
@@ -18,7 +18,8 @@ interface BaseDatasetButtonProps {
mode: "create" | "update" | "delete";
projectId: string;
className?: string;
onFormSuccess?: () => void;
size?: ButtonProps["size"];
variant?: ButtonProps["variant"];
}
interface CreateDatasetButtonProps extends BaseDatasetButtonProps {
@@ -59,8 +60,8 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
{props.mode === "update" ? (
props.icon ? (
<Button
variant="outline"
size={"icon"}
variant={props.variant || "outline"}
size={props.size || "icon"}
className={props.className}
disabled={!hasAccess}
onClick={() =>
@@ -73,7 +74,9 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
</Button>
) : (
<Button
variant="ghost"
variant={props.variant || "ghost"}
size={props.size || "icon"}
className={props.className}
onClick={() => {
setOpen(true);
capture("datasets:update_form_open", {
@@ -91,7 +94,9 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
)
) : props.mode === "delete" ? (
<Button
variant="ghost"
variant={props.variant || "ghost"}
size={props.size}
className={props.className}
onClick={() => {
setOpen(true);
capture("datasets:delete_form_open", {
@@ -104,10 +109,11 @@ export const DatasetActionButton = (props: DatasetActionButtonProps) => {
</Button>
) : (
<Button
size={props.size}
className={props.className}
disabled={!hasAccess}
onClick={() => capture("datasets:new_form_open")}
variant="secondary"
variant={props.variant || "secondary"}
>
{hasAccess ? (
<PlusIcon className="-ml-0.5 mr-1.5 h-4 w-4" aria-hidden="true" />
@@ -31,6 +31,23 @@ const formatDatasetItemData = (data: string | null | undefined) => {
};
export const datasetRouter = createTRPCRouter({
hasAny: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const dataset = await ctx.prisma.dataset.findFirst({
where: {
projectId: input.projectId,
},
select: { id: true },
take: 1,
});
return dataset !== null;
}),
allDatasetMeta: protectedProjectProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input, ctx }) => {
@@ -495,21 +495,32 @@ export const getRunItemsByRunIdOrItemId = async (
projectId: string,
runItems: DatasetRunItems[],
) => {
const minTimestamp = runItems
.map((ri) => ri.createdAt)
.sort((a, b) => a.getTime() - b.getTime())
.shift();
// We assume that all events started at most 24h before the earliest run item.
const filterTimestamp = minTimestamp
? new Date(minTimestamp.getTime() - 24 * 60 * 60 * 1000)
: undefined;
const [traceScores, observationAggregates, traceAggregate] =
await Promise.all([
getScoresForTraces({
projectId,
traceIds: runItems.map((ri) => ri.traceId),
timestamp: filterTimestamp,
}),
getLatencyAndTotalCostForObservations(
projectId,
runItems
.filter((ri) => ri.observationId !== null)
.map((ri) => ri.observationId) as string[],
filterTimestamp,
),
getLatencyAndTotalCostForObservationsByTraces(
projectId,
runItems.map((ri) => ri.traceId),
filterTimestamp,
),
]);
+52 -8
View File
@@ -128,7 +128,21 @@ const extractInputAndOutput = (
event.name === "gen_ai.content.completion",
)?.attributes;
if (input || output) {
return { input, output };
input =
input?.reduce((acc: any, attr: any) => {
acc[attr.key] = convertValueToPlainJavascript(attr.value);
return acc;
}, {}) ?? {};
output =
output?.reduce((acc: any, attr: any) => {
acc[attr.key] = convertValueToPlainJavascript(attr.value);
return acc;
}, {}) ?? {};
// Here, we are interested in the attributes of the event. Usually gen_ai.prompt and gen_ai.completion.
// We can use the current function again to extract them from the event attributes.
const { input: eventInput } = extractInputAndOutput([], input);
const { output: eventOutput } = extractInputAndOutput([], output);
return { input: eventInput || input, output: eventOutput || output };
}
// MLFlow sets mlflow.spanInputs and mlflow.spanOutputs
@@ -177,6 +191,26 @@ const extractInputAndOutput = (
return { input: null, output: null };
};
const extractEnvironment = (
attributes: Record<string, unknown>,
resourceAttributes: Record<string, unknown>,
): string => {
const environmentAttributeKeys = [
"langfuse.environment",
"deployment.environment.name",
"deployment.environment",
];
for (const key of environmentAttributeKeys) {
if (resourceAttributes[key]) {
return resourceAttributes[key] as string;
}
if (attributes[key]) {
return attributes[key] as string;
}
}
return "default";
};
const extractUserId = (
attributes: Record<string, unknown>,
): string | undefined => {
@@ -266,7 +300,11 @@ const extractUsageDetails = (
.replace("llm.token_count.", "");
const mappedUsageDetailKey =
usageDetailKeyMapping[usageDetailKey] ?? usageDetailKey;
acc[mappedUsageDetailKey] = attributes[key];
// Cast the respective key to a number
const value = Number(attributes[key]);
if (!Number.isNaN(value)) {
acc[mappedUsageDetailKey] = value;
}
return acc;
}, {});
};
@@ -305,7 +343,13 @@ export const convertOtelSpanToIngestionEvent = (
return acc;
}, {}) ?? {};
if (!span?.parentSpanId) {
const parentObservationId = span?.parentSpanId
? Buffer.from(span.parentSpanId?.data ?? span.parentSpanId).toString(
"hex",
)
: null;
if (!parentObservationId) {
// Create a trace for any root span
const trace = {
id: Buffer.from(span.traceId?.data ?? span.traceId).toString("hex"),
@@ -328,6 +372,8 @@ export const convertOtelSpanToIngestionEvent = (
attributes?.["langfuse.public"] === "true",
tags: attributes?.["langfuse.tags"] ?? [],
environment: extractEnvironment(attributes, resourceAttributes),
// Input and Output
...extractInputAndOutput(span?.events ?? [], attributes),
};
@@ -345,15 +391,13 @@ export const convertOtelSpanToIngestionEvent = (
traceId: Buffer.from(span.traceId?.data ?? span.traceId).toString(
"hex",
),
parentObservationId: span?.parentSpanId
? Buffer.from(span.parentSpanId?.data ?? span.parentSpanId).toString(
"hex",
)
: null,
parentObservationId,
name: span.name,
startTime: convertNanoTimestampToISO(span.startTimeUnixNano),
endTime: convertNanoTimestampToISO(span.endTimeUnixNano),
environment: extractEnvironment(attributes, resourceAttributes),
// Additional fields
metadata: {
attributes,
@@ -125,18 +125,17 @@ export function SetPromptVersionLabels({
isLive={label === PRODUCTION_LABEL}
/>
))}
{promptLabels.length === 0 && (
<Button
variant="outline"
className={cn(
"h-6 w-6 bg-muted-gray text-primary",
showOnlyOnHover && "opacity-0 group-hover:opacity-100",
!hasAccess && "cursor-not-allowed group-hover:opacity-50",
)}
>
<CircleFadingArrowUp className="h-3.5 w-3.5 shrink-0" />
</Button>
)}
<Button
variant="outline"
title="Add prompt version label"
className={cn(
"h-6 w-6 bg-muted-gray text-primary",
showOnlyOnHover && "opacity-0 group-hover:opacity-100",
!hasAccess && "cursor-not-allowed group-hover:opacity-50",
)}
>
<CircleFadingArrowUp className="h-3.5 w-3.5 shrink-0" />
</Button>
</div>
</PopoverTrigger>
<PopoverContent
@@ -377,7 +377,7 @@ export const PromptDetail = () => {
</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<CreateExperimentsForm
key={`create-experiment-form-${prompt.id}`}
projectId={projectId as string}
@@ -455,10 +455,12 @@ export const PromptDetail = () => {
>
<div className="mb-2 flex max-h-full min-h-0 w-full flex-col gap-2 overflow-y-auto">
{prompt.type === PromptType.Chat && chatMessages ? (
<OpenAiMessageView
messages={chatMessages}
collapseLongHistory={false}
/>
<div className="w-full">
<OpenAiMessageView
messages={chatMessages}
collapseLongHistory={false}
/>
</div>
) : typeof prompt.prompt === "string" ? (
<CodeView content={prompt.prompt} title="Text Prompt" />
) : (
@@ -36,6 +36,29 @@ const PromptFilterOptions = z.object({
});
export const promptRouter = createTRPCRouter({
hasAny: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
throwIfNoProjectAccess({
session: ctx.session,
projectId: input.projectId,
scope: "prompts:read",
});
const prompt = await ctx.prisma.prompt.findFirst({
where: {
projectId: input.projectId,
},
select: { id: true },
take: 1,
});
return prompt !== null;
}),
all: protectedProjectProcedure
.input(PromptFilterOptions)
.query(async ({ input, ctx }) => {
@@ -531,7 +531,7 @@ export function CreateLLMApiKeyForm({
{currentAdapter === LLMAdapter.Bedrock && (
<FormDescription className="text-dark-yellow">
{
"For Bedrock, the model name is the Bedrock model ID, e.g. 'eu.anthropic.claude-3-5-sonnet-20240620-v1:0'"
"For Bedrock, the model name is the Bedrock Inference Profile ID, e.g. 'eu.anthropic.claude-3-5-sonnet-20240620-v1:0'"
}
</FormDescription>
)}
@@ -55,6 +55,7 @@ export const generateObservationsForPublicApi = async (props: QueryType) => {
project_id,
type,
parent_observation_id,
environment,
start_time,
end_time,
name,
@@ -41,6 +41,7 @@ export const generateScoresForPublicApi = async (props: ScoreQueryType) => {
s.id as id,
s.project_id as project_id,
s.timestamp as timestamp,
s.environment as environment,
s.name as name,
s.value as value,
s.string_value as string_value,
@@ -93,6 +93,7 @@ export const generateTracesForPublicApi = async (
t.project_id as project_id,
t.timestamp as timestamp,
t.name as name,
t.environment as environment,
t.input as input,
t.output as output,
t.session_id as session_id,
@@ -21,6 +21,7 @@ export const APIObservation = z
parentObservationId: z.string().nullable(),
name: z.string().nullable(),
type: ObservationType,
environment: z.string().default("default"),
startTime: z.coerce.date(),
endTime: z.coerce.date().nullable(),
version: z.string().nullable(),
@@ -23,6 +23,7 @@ export const APITrace = z
release: z.string().nullable(),
version: z.string().nullable(),
projectId: z.string(),
environment: z.string().default("default"),
public: z.boolean(),
bookmarked: z.boolean(),
tags: z.array(z.string()),
+25 -2
View File
@@ -1,11 +1,28 @@
import { useRouter } from "next/router";
import { DatasetsTable } from "@/src/features/datasets/components/DatasetsTable";
import Page from "@/src/components/layouts/page";
import { api } from "@/src/utils/api";
import { DatasetsOnboarding } from "@/src/components/onboarding/DatasetsOnboarding";
export default function Traces() {
export default function Datasets() {
const router = useRouter();
const projectId = router.query.projectId as string;
// Check if the project has any datasets
const { data: hasAnyDataset, isLoading } = api.datasets.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId,
trpc: {
context: {
skipBatch: true,
},
},
},
);
const showOnboarding = !isLoading && !hasAnyDataset;
return (
<Page
headerProps={{
@@ -16,8 +33,14 @@ export default function Traces() {
href: "https://langfuse.com/docs/datasets",
},
}}
scrollable={showOnboarding}
>
<DatasetsTable projectId={projectId} />
{/* Show onboarding screen if project has no datasets */}
{showOnboarding ? (
<DatasetsOnboarding projectId={projectId} />
) : (
<DatasetsTable projectId={projectId} />
)}
</Page>
);
}
@@ -30,7 +30,7 @@ export default function LogPage() {
return (
<Page
headerProps={{
title: "Eval Log",
title: "Evaluators",
help: {
description: "View of all running evals.",
href: "https://langfuse.com/docs/scores/model-based-evals",
+1 -1
View File
@@ -147,7 +147,7 @@ export default function Dashboard() {
scrollable
headerProps={{
title: "Dashboard",
actionButtonsLeft: <SetupTracingButton />,
actionButtonsRight: <SetupTracingButton />,
}}
>
<div className="my-3 flex flex-wrap items-center justify-between gap-2">
@@ -1,11 +1,29 @@
import { useRouter } from "next/router";
import ObservationsTable from "@/src/components/table/use-cases/observations";
import Page from "@/src/components/layouts/page";
import { api } from "@/src/utils/api";
import { TracesOnboarding } from "@/src/components/onboarding/TracesOnboarding";
export default function Generations() {
const router = useRouter();
const projectId = router.query.projectId as string;
// Check if the user has any traces
const { data: hasAnyTrace, isLoading } = api.traces.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId,
trpc: {
context: {
skipBatch: true,
},
},
refetchInterval: 10_000,
},
);
const showOnboarding = !isLoading && !hasAnyTrace;
return (
<Page
headerProps={{
@@ -16,8 +34,14 @@ export default function Generations() {
href: "https://langfuse.com/docs/tracing",
},
}}
scrollable={showOnboarding}
>
<ObservationsTable projectId={projectId} />
{/* Show onboarding screen if user has no traces */}
{showOnboarding ? (
<TracesOnboarding projectId={projectId} />
) : (
<ObservationsTable projectId={projectId} />
)}
</Page>
);
}
@@ -1,7 +1,28 @@
import { useRouter } from "next/router";
import Page from "@/src/components/layouts/page";
import { PromptTable } from "@/src/features/prompts/components/prompts-table";
import { api } from "@/src/utils/api";
import { PromptsOnboarding } from "@/src/components/onboarding/PromptsOnboarding";
export default function Prompts() {
const router = useRouter();
const projectId = router.query.projectId as string;
// Check if the project has any prompts
const { data: hasAnyPrompt, isLoading } = api.prompts.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId,
trpc: {
context: {
skipBatch: true,
},
},
},
);
const showOnboarding = !isLoading && !hasAnyPrompt;
return (
<Page
headerProps={{
@@ -12,8 +33,14 @@ export default function Prompts() {
href: "https://langfuse.com/docs/prompts",
},
}}
scrollable={showOnboarding}
>
<PromptTable />
{/* Show onboarding screen if project has no prompts */}
{showOnboarding ? (
<PromptsOnboarding projectId={projectId} />
) : (
<PromptTable />
)}
</Page>
);
}
+25 -1
View File
@@ -1,11 +1,29 @@
import { useRouter } from "next/router";
import ScoresTable from "@/src/components/table/use-cases/scores";
import Page from "@/src/components/layouts/page";
import { api } from "@/src/utils/api";
import { ScoresOnboarding } from "@/src/components/onboarding/ScoresOnboarding";
export default function ScoresPage() {
const router = useRouter();
const projectId = router.query.projectId as string;
// Check if the user has any scores
const { data: hasAnyScore, isLoading } = api.scores.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId,
trpc: {
context: {
skipBatch: true,
},
},
refetchInterval: 10_000,
},
);
const showOnboarding = !isLoading && !hasAnyScore;
return (
<Page
headerProps={{
@@ -16,8 +34,14 @@ export default function ScoresPage() {
href: "https://langfuse.com/docs/scores",
},
}}
scrollable={showOnboarding}
>
<ScoresTable projectId={projectId} />
{/* Show onboarding screen if user has no scores */}
{showOnboarding ? (
<ScoresOnboarding />
) : (
<ScoresTable projectId={projectId} />
)}
</Page>
);
}
+24 -1
View File
@@ -1,11 +1,28 @@
import { useRouter } from "next/router";
import SessionsTable from "@/src/components/table/use-cases/sessions";
import Page from "@/src/components/layouts/page";
import { SessionsOnboarding } from "@/src/components/onboarding/SessionsOnboarding";
import { api } from "@/src/utils/api";
export default function Sessions() {
const router = useRouter();
const projectId = router.query.projectId as string;
const { data: hasAnySession, isLoading } = api.sessions.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId,
trpc: {
context: {
skipBatch: true,
},
},
refetchInterval: 10_000,
},
);
const showOnboarding = !isLoading && !hasAnySession;
return (
<Page
headerProps={{
@@ -16,8 +33,14 @@ export default function Sessions() {
href: "https://langfuse.com/docs/sessions",
},
}}
scrollable={showOnboarding}
>
<SessionsTable projectId={projectId} />
{/* Show onboarding screen if user has no sessions */}
{showOnboarding ? (
<SessionsOnboarding />
) : (
<SessionsTable projectId={projectId} />
)}
</Page>
);
}
@@ -24,14 +24,13 @@ import { BatchExportsSettingsPage } from "@/src/features/batch-exports/component
import { AuditLogsSettingsPage } from "@/src/ee/features/audit-log-viewer/AuditLogsSettingsPage";
import { ModelsSettings } from "@/src/features/models/components/ModelSettings";
import ConfigureRetention from "@/src/features/projects/components/ConfigureRetention";
import { env } from "@/src/env.mjs";
import ContainerPage from "@/src/components/layouts/container-page";
export default function SettingsPage() {
const { project, organization } = useQueryProject();
const router = useRouter();
const showBillingSettings = useHasEntitlement("cloud-billing");
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
const showRetentionSettings = useHasEntitlement("data-retention");
if (!project || !organization) return null;
return (
<ContainerPage
@@ -49,7 +48,7 @@ export default function SettingsPage() {
<div className="flex flex-col gap-6">
<HostNameProject />
<RenameProject />
{isLangfuseCloud && <ConfigureRetention />}
{showRetentionSettings && <ConfigureRetention />}
<div>
<Header title="Debug Information" />
<JSONView
+25 -3
View File
@@ -1,12 +1,29 @@
import { useRouter } from "next/router";
import TracesTable from "@/src/components/table/use-cases/traces";
import SetupTracingButton from "@/src/features/setup/components/SetupTracingButton";
import Page from "@/src/components/layouts/page";
import { api } from "@/src/utils/api";
import { TracesOnboarding } from "@/src/components/onboarding/TracesOnboarding";
export default function Traces() {
const router = useRouter();
const projectId = router.query.projectId as string;
// Check if the user has any traces
const { data: hasAnyTrace, isLoading } = api.traces.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId,
trpc: {
context: {
skipBatch: true,
},
},
refetchInterval: 10_000,
},
);
const showOnboarding = !isLoading && !hasAnyTrace;
return (
<Page
headerProps={{
@@ -16,10 +33,15 @@ export default function Traces() {
"A trace represents a single function/api invocation. Traces contain observations. See docs to learn more.",
href: "https://langfuse.com/docs/tracing",
},
actionButtonsRight: <SetupTracingButton />,
}}
scrollable={showOnboarding}
>
<TracesTable projectId={projectId} />
{/* Show onboarding screen if user has no traces */}
{showOnboarding ? (
<TracesOnboarding projectId={projectId} />
) : (
<TracesTable projectId={projectId} />
)}
</Page>
);
}
+42 -12
View File
@@ -23,6 +23,7 @@ import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableC
import { useTableDateRange } from "@/src/hooks/useTableDateRange";
import { useDebounce } from "@/src/hooks/useDebounce";
import Page from "@/src/components/layouts/page";
import { UsersOnboarding } from "@/src/components/onboarding/UsersOnboarding";
type RowData = {
userId: string;
@@ -37,6 +38,44 @@ export default function UsersPage() {
const router = useRouter();
const projectId = router.query.projectId as string;
// Check if the user has any users
const { data: hasAnyUser, isLoading } = api.users.hasAny.useQuery(
{ projectId },
{
enabled: !!projectId,
trpc: {
context: {
skipBatch: true,
},
},
refetchInterval: 10_000,
},
);
const showOnboarding = !isLoading && !hasAnyUser;
return (
<Page
headerProps={{
title: "Users",
help: {
description:
"Attribute data in Langfuse to a user by adding a userId to your traces. See docs to learn more.",
href: "https://langfuse.com/docs/user-explorer",
},
}}
scrollable={showOnboarding}
>
{/* Show onboarding screen if user has no users */}
{showOnboarding ? <UsersOnboarding /> : <UsersTable />}
</Page>
);
}
const UsersTable = () => {
const router = useRouter();
const projectId = router.query.projectId as string;
const [userFilterState, setUserFilterState] = useQueryFilterState(
[],
"users",
@@ -244,16 +283,7 @@ export default function UsersPage() {
];
return (
<Page
headerProps={{
title: "Users",
help: {
description:
"Attribute data in Langfuse to a user by adding a userId to your traces. See docs to learn more.",
href: "https://langfuse.com/docs/user-explorer",
},
}}
>
<>
<DataTableToolbar
filterColumnDefinition={usersTableCols}
filterState={userFilterState}
@@ -308,6 +338,6 @@ export default function UsersPage() {
state: paginationState,
}}
/>
</Page>
</>
);
}
};
@@ -58,7 +58,8 @@ export default function UserPage() {
title: userId,
breadcrumb: [{ name: "Users", href: `/project/${projectId}/users` }],
itemType: "USER",
actionButtonsLeft: (
actionButtonsRight: (
<>
<ActionButton
href={`/project/${projectId}?filter=user%3Bstring%3B%3B%3D%3B${userId}`} // dashboard filter serialization
@@ -67,17 +68,15 @@ export default function UserPage() {
>
Dashboard
</ActionButton>
<DetailPageNav
currentId={encodeURIComponent(userId)}
path={(entry) =>
`/project/${projectId}/users/${encodeURIComponent(entry.id)}`
}
listKey="users"
/>
</>
),
actionButtonsRight: (
<DetailPageNav
currentId={encodeURIComponent(userId)}
path={(entry) =>
`/project/${projectId}/users/${encodeURIComponent(entry.id)}`
}
listKey="users"
/>
),
}}
>
<>
+59 -52
View File
@@ -38,8 +38,8 @@ import {
getScoreById,
convertDateToClickhouseDateTime,
searchExistingAnnotationScore,
hasAnyScore,
} from "@langfuse/shared/src/server";
import { env } from "@/src/env.mjs";
import { v4 } from "uuid";
const ScoreFilterOptions = z.object({
@@ -166,9 +166,41 @@ export const scoresRouter = createTRPCRouter({
scope: "scores:CUD",
});
const clickhouseTrace = await getTraceById(
input.traceId,
input.projectId,
);
if (!clickhouseTrace) {
logger.error(
`No trace with id ${input.traceId} in project ${input.projectId} in Clickhouse`,
);
throw new LangfuseNotFoundError(
`No trace with id ${input.traceId} in project ${input.projectId} in Clickhouse`,
);
}
const clickhouseScore = await searchExistingAnnotationScore(
input.projectId,
input.traceId,
input.observationId ?? null,
input.name,
input.configId,
);
if (clickhouseScore) {
logger.error(
`Score for name ${input.name} already exists for trace ${input.traceId} in project ${input.projectId}`,
);
throw new InvalidRequestError(
`Score for name ${input.name} already exists for trace ${input.traceId} in project ${input.projectId}`,
);
}
const score = {
id: v4(),
projectId: input.projectId,
environment: input.environment ?? "default",
traceId: input.traceId,
observationId: input.observationId ?? null,
value: input.value ?? null,
@@ -185,57 +217,23 @@ export const scoresRouter = createTRPCRouter({
timestamp: new Date(),
};
const hasClickhouseConfigured = env.CLICKHOUSE_URL;
if (hasClickhouseConfigured) {
const clickhouseTrace = await getTraceById(
input.traceId,
input.projectId,
);
if (!clickhouseTrace) {
logger.error(
`No trace with id ${input.traceId} in project ${input.projectId} in Clickhouse`,
);
throw new LangfuseNotFoundError(
`No trace with id ${input.traceId} in project ${input.projectId} in Clickhouse`,
);
}
const clickhouseScore = await searchExistingAnnotationScore(
input.projectId,
input.traceId,
input.observationId ?? null,
input.name,
input.configId,
);
if (clickhouseScore) {
logger.error(
`Score for name ${input.name} already exists for trace ${input.traceId} in project ${input.projectId}`,
);
throw new InvalidRequestError(
`Score for name ${input.name} already exists for trace ${input.traceId} in project ${input.projectId}`,
);
}
await upsertScore({
id: score.id, // Reuse ID that was generated by Prisma
timestamp: convertDateToClickhouseDateTime(new Date()),
project_id: input.projectId,
trace_id: input.traceId,
observation_id: input.observationId,
name: input.name,
value: input.value !== null ? input.value : undefined,
source: ScoreSource.ANNOTATION,
comment: input.comment,
author_user_id: ctx.session.user.id,
config_id: input.configId,
data_type: input.dataType,
string_value: input.stringValue,
queue_id: input.queueId,
});
}
await upsertScore({
id: score.id, // Reuse ID that was generated by Prisma
timestamp: convertDateToClickhouseDateTime(new Date()),
project_id: input.projectId,
environment: input.environment ?? "default",
trace_id: input.traceId,
observation_id: input.observationId,
name: input.name,
value: input.value !== null ? input.value : undefined,
source: ScoreSource.ANNOTATION,
comment: input.comment,
author_user_id: ctx.session.user.id,
config_id: input.configId,
data_type: input.dataType,
string_value: input.stringValue,
queue_id: input.queueId,
});
await auditLog({
session: ctx.session,
@@ -379,4 +377,13 @@ export const scoresRouter = createTRPCRouter({
dataType: dataType,
}));
}),
hasAny: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input }) => {
return await hasAnyScore(input.projectId);
}),
});
+10
View File
@@ -30,6 +30,7 @@ import {
getPublicSessionsFilter,
logger,
getSessionsWithMetrics,
hasAnySession,
} from "@langfuse/shared/src/server";
import { chunk } from "lodash";
@@ -41,6 +42,15 @@ const SessionFilterOptions = z.object({
});
export const sessionRouter = createTRPCRouter({
hasAny: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input }) => {
return await hasAnySession(input.projectId);
}),
all: protectedProjectProcedure
.input(SessionFilterOptions)
.query(async ({ input, ctx }) => {
+11
View File
@@ -9,6 +9,7 @@ import {
getTotalUserCount,
getTracesGroupedByUsers,
getUserMetrics,
hasAnyUser,
} from "@langfuse/shared/src/server";
const UserFilterOptions = z.object({
@@ -25,6 +26,16 @@ const UserAllOptions = UserFilterOptions.extend({
});
export const userRouter = createTRPCRouter({
hasAny: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input }) => {
return await hasAnyUser(input.projectId);
}),
all: protectedProjectProcedure
.input(UserAllOptions)
.query(async ({ input, ctx }) => {
+4
View File
@@ -180,6 +180,10 @@ export const protectedProcedure = withOtelTracingProcedure
.use(withErrorHandling)
.use(enforceUserIsAuthed);
export const protectedProcedureWithoutTracing = t.procedure
.use(withErrorHandling)
.use(enforceUserIsAuthed);
const inputProjectSchema = z.object({
projectId: z.string(),
});
+2 -2
View File
@@ -50,6 +50,7 @@ import {
} from "@/src/features/entitlements/server/getPlan";
import { projectRoleAccessRights } from "@/src/features/rbac/constants/projectAccessRights";
import { hasEntitlementBasedOnPlan } from "@/src/features/entitlements/server/hasEntitlement";
import { getSSOBlockedDomains } from "@/src/features/auth-credentials/server/signupApiHandler";
function canCreateOrganizations(userEmail: string | null): boolean {
const instancePlan = getSelfHostedInstancePlanServerSide();
@@ -111,8 +112,7 @@ const staticProviders: Provider[] = [
}
}
const blockedDomains =
env.AUTH_DOMAINS_WITH_SSO_ENFORCEMENT?.split(",") ?? [];
const blockedDomains = getSSOBlockedDomains();
const domain = credentials.email.split("@")[1]?.toLowerCase();
if (domain && blockedDomains.includes(domain)) {
throw new Error(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.30.0",
"version": "3.34.1",
"description": "",
"license": "MIT",
"private": true,
+96 -84
View File
@@ -732,99 +732,101 @@ describe("eval service tests", () => {
expect(jobs.length).toBe(0);
}, 10_000);
});
test("does create eval for trace which is way in the past if timestamp is provided", async () => {
const traceId = randomUUID();
test("does create eval for trace which is way in the past if timestamp is provided", async () => {
const traceId = randomUUID();
const timestamp = new Date(Date.now() - 1000 * 60 * 60 * 24 * 365 * 1);
const trace = createTrace({
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
id: traceId,
timestamp: timestamp.getTime(),
});
const timestamp = new Date(Date.now() - 1000 * 60 * 60 * 24 * 365 * 1);
const trace = createTrace({
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
id: traceId,
timestamp: timestamp.getTime(),
});
await createTracesCh([trace]);
await createTracesCh([trace]);
const jobConfiguration = await prisma.jobConfiguration.create({
data: {
id: randomUUID(),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
filter: JSON.parse("[]"),
jobType: "EVAL",
delay: 0,
sampling: new Decimal("1"),
targetObject: "trace",
scoreName: "score",
variableMapping: JSON.parse("[]"),
timeScope: ["NEW"],
},
});
const jobConfiguration = await prisma.jobConfiguration.create({
data: {
id: randomUUID(),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
filter: JSON.parse("[]"),
jobType: "EVAL",
delay: 0,
sampling: new Decimal("1"),
targetObject: "trace",
scoreName: "score",
variableMapping: JSON.parse("[]"),
timeScope: ["NEW"],
},
});
const payload = {
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
traceId: traceId,
configId: jobConfiguration.id,
timestamp: timestamp,
};
await createEvalJobs({
event: payload,
enforcedJobTimeScope: "NEW", // the config must contain NEW
});
const jobs = await kyselyPrisma.$kysely
.selectFrom("job_executions")
.selectAll()
.where("project_id", "=", "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a")
.where("job_configuration_id", "in", [jobConfiguration.id])
.where("job_input_trace_id", "=", traceId)
.execute();
expect(jobs.length).toBe(1);
}, 10_000);
test("creates eval for trace with timestamp in the future", async () => {
const traceId = randomUUID();
await prisma.jobConfiguration.create({
data: {
id: randomUUID(),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
filter: JSON.parse("[]"),
jobType: "EVAL",
delay: 0,
sampling: new Decimal("1"),
targetObject: "trace",
scoreName: "score",
variableMapping: JSON.parse("[]"),
timeScope: ["NEW"],
},
});
const trace = createTrace({
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
id: traceId,
timestamp: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 1).getTime(),
});
await createTracesCh([trace]);
await createEvalJobs({
event: {
const payload = {
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
traceId: traceId,
},
});
configId: jobConfiguration.id,
timestamp: timestamp,
};
const jobs = await kyselyPrisma.$kysely
.selectFrom("job_executions")
.selectAll()
.where("project_id", "=", "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a")
.execute();
await createEvalJobs({
event: payload,
enforcedJobTimeScope: "NEW", // the config must contain NEW
});
expect(jobs.length).toBe(1);
}, 10_000);
const jobs = await kyselyPrisma.$kysely
.selectFrom("job_executions")
.selectAll()
.where("project_id", "=", "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a")
.where("job_configuration_id", "in", [jobConfiguration.id])
.where("job_input_trace_id", "=", traceId)
.execute();
expect(jobs.length).toBe(1);
}, 10_000);
test("creates eval for trace with timestamp in the future", async () => {
const traceId = randomUUID();
await prisma.jobConfiguration.create({
data: {
id: randomUUID(),
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
filter: JSON.parse("[]"),
jobType: "EVAL",
delay: 0,
sampling: new Decimal("1"),
targetObject: "trace",
scoreName: "score",
variableMapping: JSON.parse("[]"),
timeScope: ["NEW"],
},
});
const trace = createTrace({
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
id: traceId,
timestamp: new Date(
Date.now() + 1000 * 60 * 60 * 24 * 365 * 1,
).getTime(),
});
await createTracesCh([trace]);
await createEvalJobs({
event: {
projectId: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
traceId: traceId,
},
});
const jobs = await kyselyPrisma.$kysely
.selectFrom("job_executions")
.selectAll()
.where("project_id", "=", "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a")
.execute();
expect(jobs.length).toBe(1);
}, 10_000);
});
describe("execute evals", () => {
test("evals a valid 'trace' event", async () => {
@@ -1362,6 +1364,7 @@ describe("eval service tests", () => {
id: traceId,
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
user_id: "a",
environment: "production",
input: JSON.stringify({ input: "This is a great prompt" }),
output: JSON.stringify({ output: "This is a great response" }),
timestamp: convertDateToClickhouseDateTime(new Date()),
@@ -1393,10 +1396,12 @@ describe("eval service tests", () => {
{
value: '{"input":"This is a great prompt"}',
var: "input",
environment: "production",
},
{
value: '{"output":"This is a great response"}',
var: "output",
environment: "production",
},
]);
}, 10_000);
@@ -1421,6 +1426,7 @@ describe("eval service tests", () => {
project_id: "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a",
name: "great-llm-name",
type: "GENERATION",
environment: "production",
input: JSON.stringify({ huhu: "This is a great prompt" }),
output: JSON.stringify({ haha: "This is a great response" }),
start_time: convertDateToClickhouseDateTime(new Date()),
@@ -1454,10 +1460,12 @@ describe("eval service tests", () => {
{
value: '{"huhu":"This is a great prompt"}',
var: "input",
environment: "production",
},
{
value: '{"haha":"This is a great response"}',
var: "output",
environment: "production",
},
]);
}, 10_000);
@@ -1544,10 +1552,12 @@ describe("eval service tests", () => {
expect(result).toEqual([
{
environment: "default",
value: "",
var: "input",
},
{
environment: "default",
value: "",
var: "output",
},
@@ -1622,10 +1632,12 @@ describe("eval service tests", () => {
expect(result).toEqual([
{
environment: "default",
value: '{"huhu":"This is a great prompt again"}',
var: "input",
},
{
environment: "default",
value: '{"haha":"This is a great response again"}',
var: "output",
},
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.30.0";
export const VERSION = "v3.34.1";
+30 -2
View File
@@ -1446,9 +1446,9 @@
{
"id": "cm7ka7561000108js3t9tb3at",
"model_name": "claude-3.7-sonnet-20250219",
"match_pattern": "(?i)^(claude-3.7-sonnet-20250219|anthropic\\.claude-3.7-sonnet-20250219-v2:0|claude-3-7-sonnet-V2@20250219)$",
"match_pattern": "(?i)^(claude-3.7-sonnet-20250219|anthropic\\.claude-3.7-sonnet-20250219-v1:0|claude-3-7-sonnet-V1@20250219)$",
"created_at": "2025-02-25T09:35:39.000Z",
"updated_at": "2025-02-25T09:35:39.000Z",
"updated_at": "2025-02-27T12:07:29.000Z",
"prices": {
"input": 3e-6,
"input_tokens": 3e-6,
@@ -1480,5 +1480,33 @@
},
"tokenizer_config": null,
"tokenizer_id": "claude"
},
{
"id": "cm7nusjvk0000tvmz71o85jwg",
"model_name": "gpt-4.5-preview",
"match_pattern": "(?i)^(gpt-4.5-preview)$",
"created_at": "2025-02-27T21:26:54.132Z",
"updated_at": "2025-02-27T21:26:54.132Z",
"prices": {
"input": 75e-6,
"input_cached_tokens": 37.5e-6,
"input_cached_text_tokens": 37.5e-6,
"input_cache_read": 37.5e-6,
"output": 150e-6
}
},
{
"id": "cm7nusn640000tvmzf10z2x65",
"model_name": "gpt-4.5-preview-2025-02-27",
"match_pattern": "(?i)^(gpt-4.5-preview-2025-02-27)$",
"created_at": "2025-02-27T21:26:54.132Z",
"updated_at": "2025-02-27T21:26:54.132Z",
"prices": {
"input": 75e-6,
"input_cached_tokens": 37.5e-6,
"input_cached_text_tokens": 37.5e-6,
"input_cache_read": 37.5e-6,
"output": 150e-6
}
}
]
+12 -7
View File
@@ -371,6 +371,9 @@ export const evaluate = async ({
`Evaluating job ${event.jobExecutionId} extracted variables ${JSON.stringify(mappingResult)} `,
);
// Get environment from trace or observation variables
const environment = mappingResult.find((r) => r.environment)?.environment;
// compile the prompt and send out the LLM request
let prompt;
try {
@@ -461,6 +464,7 @@ export const evaluate = async ({
value: parsedLLMOutput.score,
comment: parsedLLMOutput.reasoning,
source: ScoreSource.EVAL,
environment: environment ?? "default",
};
// Write score to S3 and ingest into queue for Clickhouse processing
@@ -542,7 +546,7 @@ export async function extractVariablesFromTracingData({
// this here are variables which were inserted by users. Need to validate before DB query.
variableMapping: z.infer<typeof variableMappingList>;
datasetItemId?: string;
}): Promise<{ var: string; value: string }[]> {
}): Promise<{ var: string; value: string; environment?: string }[]> {
return Promise.all(
variables.map(async (variable) => {
const mapping = variableMapping.find(
@@ -616,10 +620,7 @@ export async function extractVariablesFromTracingData({
return { var: variable, value: "" };
}
const trace: Record<string, unknown> | undefined = await getTraceById(
traceId,
projectId,
);
const trace = await getTraceById(traceId, projectId);
// user facing errors
if (!trace) {
@@ -634,6 +635,7 @@ export async function extractVariablesFromTracingData({
return {
var: variable,
value: parseDatabaseRowToString(trace, mapping),
environment: trace.environment,
};
}
@@ -656,7 +658,7 @@ export async function extractVariablesFromTracingData({
return { var: variable, value: "" };
}
const observation: Record<string, unknown> | undefined = (
const observation = (
await getObservationForTraceIdByName(
traceId,
projectId,
@@ -678,7 +680,10 @@ export async function extractVariablesFromTracingData({
return {
var: variable,
value: parseUnknownToString(observation[mapping.selectedColumnId]),
value: parseUnknownToString(
(observation as Record<string, unknown>)[mapping.selectedColumnId],
),
environment: observation.environment,
};
}
+1
View File
@@ -7,6 +7,7 @@ const EnvSchema = z.object({
.enum(["development", "test", "production"])
.default("development"),
DATABASE_URL: z.string(),
HOSTNAME: z.string().default("0.0.0.0"),
PORT: z.coerce
.number({
description:
+2 -2
View File
@@ -3,6 +3,6 @@ import app from "./app";
import { env } from "./env";
import { logger } from "@langfuse/shared/src/server";
export const server = app.listen(env.PORT, () => {
logger.info(`Listening: http://localhost:${env.PORT}`);
export const server = app.listen(env.PORT, env.HOSTNAME, () => {
logger.info(`Listening: http://${env.HOSTNAME}:${env.PORT}`);
});
+14 -1
View File
@@ -61,13 +61,20 @@ const immutableEntityKeys: {
[TableName.Scores]: (keyof ScoreRecordInsertType)[];
[TableName.Observations]: (keyof ObservationRecordInsertType)[];
} = {
[TableName.Traces]: ["id", "project_id", "timestamp", "created_at"],
[TableName.Traces]: [
"id",
"project_id",
"timestamp",
"created_at",
"environment",
],
[TableName.Scores]: [
"id",
"project_id",
"timestamp",
"trace_id",
"created_at",
"environment",
],
[TableName.Observations]: [
"id",
@@ -75,6 +82,7 @@ const immutableEntityKeys: {
"trace_id",
"start_time",
"created_at",
"environment",
],
};
@@ -171,6 +179,7 @@ export class IngestionService {
return {
id: entityId,
project_id: projectId,
environment: validatedScore.environment,
timestamp: this.getMillisecondTimestamp(scoreEvent.timestamp),
name: validatedScore.name,
value: validatedScore.value,
@@ -411,6 +420,7 @@ export class IngestionService {
id: finalObservationRecord.id,
timestamp: finalObservationRecord.start_time,
project_id: projectId,
environment: finalObservationRecord.environment,
created_at: Date.now(),
updated_at: Date.now(),
metadata: {},
@@ -945,6 +955,7 @@ export class IngestionService {
release: trace.body.release,
version: trace.body.version,
project_id: projectId,
environment: trace.body.environment,
public: trace.body.public ?? false,
bookmarked: false,
tags: trace.body.tags ?? [],
@@ -1058,6 +1069,8 @@ export class IngestionService {
trace_id: obs.body.traceId ?? v4(),
type: observationType,
name: obs.body.name,
environment:
"environment" in obs.body ? obs.body.environment : "default",
start_time: this.getMillisecondTimestamp(
obs.body.startTime ?? obs.timestamp,
),
@@ -23,6 +23,7 @@ import { IngestionService } from "../../IngestionService";
import { ModelUsageUnit, ScoreSource } from "@langfuse/shared";
const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
const environment = "default";
const IngestionEventBatchSchema = z.array(ingestionEvent);
describe("Ingestion end-to-end tests", () => {
@@ -66,6 +67,7 @@ describe("Ingestion end-to-end tests", () => {
body: {
name: traceName,
timestamp,
environment,
},
},
];
@@ -368,6 +370,7 @@ describe("Ingestion end-to-end tests", () => {
release: "1.0.0",
version: "2.0.0",
tags: ["tag-1", "tag-2"],
environment,
},
},
];
@@ -388,6 +391,7 @@ describe("Ingestion end-to-end tests", () => {
input: { key: "value" },
metadata: { key: "value" },
version: "2.0.0",
environment,
},
},
{
@@ -400,6 +404,7 @@ describe("Ingestion end-to-end tests", () => {
usage: testConfig.usage,
usageDetails: testConfig.usageDetails,
costDetails: testConfig.costDetails,
environment,
},
},
]);
@@ -419,6 +424,7 @@ describe("Ingestion end-to-end tests", () => {
input: { input: "value" },
metadata: { meta: "value" },
version: "2.0.0",
environment,
},
},
];
@@ -435,6 +441,7 @@ describe("Ingestion end-to-end tests", () => {
value: 100.5,
source: ScoreSource.EVAL,
traceId: traceId,
environment,
},
},
];
@@ -755,6 +762,7 @@ describe("Ingestion end-to-end tests", () => {
id: traceId,
name: "trace-name",
timestamp: new Date().toISOString(),
environment,
},
},
];
@@ -775,6 +783,7 @@ describe("Ingestion end-to-end tests", () => {
},
input: "This is a great prompt",
output: "This is a great gpt output",
environment,
},
},
];
@@ -836,6 +845,7 @@ describe("Ingestion end-to-end tests", () => {
body: {
id: traceId,
timestamp: new Date().toISOString(),
environment,
},
},
];
@@ -849,6 +859,7 @@ describe("Ingestion end-to-end tests", () => {
id: spanId,
traceId: traceId,
startTime: new Date().toISOString(),
environment,
},
},
{
@@ -860,6 +871,7 @@ describe("Ingestion end-to-end tests", () => {
traceId: traceId,
name: "span-name",
startTime: new Date().toISOString(),
environment,
},
},
];
@@ -874,6 +886,7 @@ describe("Ingestion end-to-end tests", () => {
traceId: traceId,
startTime: new Date().toISOString(),
parentObservationId: spanId,
environment: environment,
modelParameters: { someKey: ["user-1", "user-2"] },
},
},
@@ -885,6 +898,7 @@ describe("Ingestion end-to-end tests", () => {
id: generationId,
name: "generation-name",
startTime: new Date().toISOString(),
environment,
},
},
];
@@ -900,6 +914,7 @@ describe("Ingestion end-to-end tests", () => {
name: "event-name",
startTime: new Date().toISOString(),
parentObservationId: generationId,
environment,
},
},
];
@@ -917,6 +932,7 @@ describe("Ingestion end-to-end tests", () => {
source: ScoreSource.API,
value: 100.5,
observationId: generationId,
environment,
},
},
];
@@ -1014,6 +1030,7 @@ describe("Ingestion end-to-end tests", () => {
release: "1.0.0",
version: "2.0.0",
tags: ["tag-1", "tag-2", "tag-2"],
environment,
},
},
];
@@ -1038,6 +1055,7 @@ describe("Ingestion end-to-end tests", () => {
name: "trace-name",
userId: "user-2",
tags: ["tag-1", "tag-4", "tag-3"],
environment,
},
},
];
@@ -1084,6 +1102,7 @@ describe("Ingestion end-to-end tests", () => {
timestamp: latestEvent.toISOString(),
name: "trace-name",
userId: "user-1",
environment,
},
},
{
@@ -1095,6 +1114,7 @@ describe("Ingestion end-to-end tests", () => {
timestamp: new Date(oldEvent).toISOString(),
name: "trace-name",
userId: "user-2",
environment,
},
},
];
@@ -1177,6 +1197,7 @@ describe("Ingestion end-to-end tests", () => {
max_tokens: 1000,
},
usage: null,
environment,
},
},
{
@@ -1195,6 +1216,7 @@ describe("Ingestion end-to-end tests", () => {
total: -3,
unit: "TOKENS",
},
environment,
},
},
];
@@ -1291,6 +1313,7 @@ describe("Ingestion end-to-end tests", () => {
max_tokens: 1000,
},
usage: null,
environment,
},
},
{
@@ -1309,6 +1332,7 @@ describe("Ingestion end-to-end tests", () => {
total: 1313,
unit: "TOKENS",
},
environment,
},
},
];
@@ -1364,6 +1388,7 @@ describe("Ingestion end-to-end tests", () => {
startTime: new Date().toISOString(),
output: "to overwrite",
usage: undefined,
environment,
},
},
];
@@ -1386,6 +1411,7 @@ describe("Ingestion end-to-end tests", () => {
traceId: traceId,
output: "overwritten",
usage: undefined,
environment,
},
},
];
@@ -1421,6 +1447,7 @@ describe("Ingestion end-to-end tests", () => {
type: "GENERATION",
startTime: new Date().toISOString(),
output: { key: "this is a great gpt output" },
environment,
},
},
{
@@ -1435,6 +1462,7 @@ describe("Ingestion end-to-end tests", () => {
input: { key: "value" },
output: "should be overwritten",
model: "gpt-3.5",
environment,
},
},
];
@@ -1480,6 +1508,7 @@ describe("Ingestion end-to-end tests", () => {
timestamp,
name: "trace-name",
userId: "user-1",
environment,
},
},
];
@@ -1496,6 +1525,7 @@ describe("Ingestion end-to-end tests", () => {
startTime: new Date().toISOString(),
name: "LiteLLM.run",
// usage: null,
environment,
},
},
];
@@ -1544,6 +1574,7 @@ describe("Ingestion end-to-end tests", () => {
outputCost: 0.0007695,
totalCost: 0.001412,
},
environment,
},
},
];
@@ -1608,6 +1639,7 @@ describe("Ingestion end-to-end tests", () => {
name: "trace-name",
timestamp: new Date().toISOString(),
userId: "user-1",
environment,
},
},
];
@@ -1625,6 +1657,7 @@ describe("Ingestion end-to-end tests", () => {
name: "generation-name",
input: { key: "value" },
model: "gpt-3.5",
environment,
},
},
{
@@ -1635,6 +1668,7 @@ describe("Ingestion end-to-end tests", () => {
id: generationId,
type: "GENERATION",
output: { key: "this is a great gpt output" },
environment,
},
},
];
@@ -1710,6 +1744,7 @@ describe("Ingestion end-to-end tests", () => {
name: "trace-name",
timestamp: new Date().toISOString(),
userId: "user-1",
environment,
},
},
];
@@ -1723,6 +1758,7 @@ describe("Ingestion end-to-end tests", () => {
id: generationId,
type: "GENERATION",
output: { key: "this is a great gpt output" },
environment,
},
},
{
@@ -1737,6 +1773,7 @@ describe("Ingestion end-to-end tests", () => {
name: "generation-name",
input: { key: "value" },
model: "gpt-3.5",
environment,
},
},
];
@@ -1795,6 +1832,7 @@ describe("Ingestion end-to-end tests", () => {
metadata: { key: "value" },
release: "1.0.0",
version: "2.0.0",
environment,
},
},
{
@@ -1808,6 +1846,7 @@ describe("Ingestion end-to-end tests", () => {
// Do not set user_id here to validate behaviour for missing fields
release: null,
version: undefined,
environment,
},
},
];
@@ -1899,6 +1938,7 @@ describe("Ingestion end-to-end tests", () => {
timestamp: new Date().toISOString(),
userId: "user-1",
metadata: inputs[0],
environment,
},
},
{
@@ -1910,6 +1950,7 @@ describe("Ingestion end-to-end tests", () => {
name: "trace-name",
timestamp: new Date().toISOString(),
metadata: inputs[1],
environment,
},
},
];
@@ -1926,6 +1967,7 @@ describe("Ingestion end-to-end tests", () => {
type: "GENERATION",
name: "generation-name",
metadata: inputs[0],
environment,
},
},
{
@@ -1938,6 +1980,7 @@ describe("Ingestion end-to-end tests", () => {
startTime: new Date().toISOString(),
type: "GENERATION",
metadata: inputs[1],
environment,
},
},
];