Compare commits

...
10 Commits
Author SHA1 Message Date
steffen911 fae508d820 chore: release v3.45.2 2025-03-26 09:59:47 +01:00
Steffen SchmitzandGitHub a6e581d349 fix: use correct column name for filtering traces bar chart (#6215) 2025-03-26 08:56:09 +00:00
Max DeichmannandGitHub 9e77738a2f fix: fix by model charts (#6208)
* fix

* push

* push

* push
2025-03-25 20:56:00 +00:00
Marc Klingen d15c0a27f5 chore: release v3.45.1 2025-03-25 19:49:44 +01:00
Marc KlingenandGitHub a8626841b5 chore(cloud): update blobstorage integration entitlement (#6209) 2025-03-25 18:40:57 +00:00
marliessophieandGitHub 5945a93b7d fix(evaluator-config): preview execution of dataset run items (#6206)
* chore(evaluator-config): preview execution of dataset run items

* push
2025-03-25 17:44:11 +00:00
Steffen SchmitzandGitHub 9c447de584 chore: auto-close stale issues (#6207) 2025-03-25 17:21:44 +00:00
Max DeichmannGitHubellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
48df0e04de fix: fix model charts (#6202)
* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* Update packages/shared/src/server/repositories/dashboards.ts

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

* Update web/src/__tests__/async/repositories/dashboard-repository.servertest.ts

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

* fix

* fix

* fix

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-25 17:12:50 +00:00
Steffen SchmitzandGitHub 0897ac753b feat: add custom-query backend (#6006)
* feat: add self-serve dashboard backend poc

* chore: data model thoughts

* chore: create initial query builder example and test case

* chore: handle empty dimensions and metrics

* chore: lint

* chore: extend filter conditions to account for timestamps

* chore: refactor query builder into sub-functions

* chore: use template queries for user-supplied values

* chore: add query builder tests

* chore: handle time dimension

* chore: add trpc endpoint to execute custom clickhouse query

* chore: add a query playground component to test a couple of queries

* chore: handle multiple joins

* chore: add observations table to data model

* chore: fix bug in data model

* chore: add test case to compare with old dashboard results

* chore: add scaffold for additional views

* expand the users view and add segments to filter scores subviews

* chore: add test cases for score views

* chore: drop users and sessions for now

* chore: add scores aggregate and observations cost tests

* chore: add sql injection tests

* chore: lint

* chore: fix dashboard test cases

* chore: drop users query test

* chore: add order by logic

* chore: fill timeseries values

* chore: use new query function on dashboard

* chore: convert TracesBarListChart.tsx to new query endpoint

* chore: make chart data compile

* chore: update tests

* chore: add tags to custom queries

* chore: typing

* chore: suffix join condition with sql

* chore: add typing in queryBuidler

* chore: limit playground to cloud admin users

* chore: add util to map legacy dashboard columns to new model

* chore: separate time filter state for TracesBarListChart.tsx

* chore: pass timestamps directly into chart components

* chore: add userId and sessionId on observations

* chore: pick auto time granularity based on hours
2025-03-25 14:54:37 +00:00
Marc KlingenandGitHub d00c63100e chore(cloud): remove rate limits from projects api as it is used by auth_check in sdks (#6193)
* chore(cloud): remove rate limits from projects api as it is used by auth_check in sdks

* address feedback

* improve comment
2025-03-25 14:52:23 +00:00
33 changed files with 6009 additions and 285 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale. Please reopen if the issue persists."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "langfuse",
"version": "3.45.0",
"version": "3.45.2",
"author": "engineering@langfuse.com",
"license": "MIT",
"private": true,
@@ -2,6 +2,11 @@ export const ClickhouseTableNames = {
traces: "traces",
observations: "observations",
scores: "scores",
// Virtual tables for dashboards
// TODO: Check if we can do this more elegantly
scores_numeric: "scores_numeric",
scores_categorical: "scores_categorical",
} as const;
export type ClickhouseTableName = keyof typeof ClickhouseTableNames;
@@ -18,3 +18,4 @@ export {
type ClickhouseOperator,
} from "./clickhouse-sql/clickhouse-filter";
export { orderByToClickhouseSql } from "./clickhouse-sql/orderby-factory";
export { createFilterFromFilterState } from "./clickhouse-sql/factory";
@@ -253,7 +253,7 @@ export const groupTracesByTime = async (
}));
};
export const getObservationUsageByTime = async (
export const getTotalObservationUsageByTimeByModel = async (
projectId: string,
filter: FilterState,
) => {
@@ -286,8 +286,8 @@ export const getObservationUsageByTime = async (
const query = `
SELECT
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")},
sumMap(usage_details) as units,
sumMap(cost_details) as cost,
sumMap(usage_details)['total'] as units,
sumMap(cost_details)['total'] as cost,
provided_model_name
FROM observations o FINAL
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
@@ -301,8 +301,8 @@ export const getObservationUsageByTime = async (
const result = await queryClickhouse<{
start_time: string;
units: Record<string, number>;
cost: Record<string, number>;
units: string;
cost: string;
provided_model_name: string;
}>({
query,
@@ -324,23 +324,209 @@ export const getObservationUsageByTime = async (
});
return result.map((row) => ({
start_time: parseClickhouseUTCDateTimeFormat(row.start_time),
units: Object.fromEntries(
Object.entries(row.units ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
cost: Object.fromEntries(
Object.entries(row.cost ?? {}).map(([key, value]) => [
key,
Number(value),
]),
),
provided_model_name: row.provided_model_name,
startTime: parseClickhouseUTCDateTimeFormat(row.start_time),
units: Number(row.units),
cost: Number(row.cost),
model: row.provided_model_name,
}));
};
export const getObservationCostByTypeByTime = async (
projectId: string,
filter: FilterState,
) => {
const { envFilter, remainingFilters } =
extractEnvironmentFilterFromFilters(filter);
const environmentFilter = new FilterList(
convertEnvFilterToClickhouseFilter(envFilter),
).apply();
const chFilter = new FilterList(
createFilterFromFilterState(remainingFilters, dashboardColumnDefinitions),
);
const appliedFilter = chFilter.apply();
const tracesFilter = chFilter.find((f) => f.clickhouseTable === "traces");
const timeFilter = tracesFilter
? (chFilter.find(
(f) =>
f.clickhouseTable === "observations" &&
f.field.includes("start_time") &&
(f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined)
: undefined;
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
filter,
"start_time",
);
const query = `
SELECT
start_time,
groupArray((cost_key, cost_sum)) AS costs
FROM (
SELECT
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")},
cost_key,
SUM(cost) AS cost_sum
FROM
observations o FINAL
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
ARRAY JOIN
mapKeys(cost_details) AS cost_key,
mapValues(cost_details) AS cost
WHERE project_id = {projectId: String}
AND ${appliedFilter.query}
${environmentFilter.query ? `AND ${environmentFilter.query}` : ""}
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
GROUP BY
start_time,
cost_key
)
GROUP BY
start_time
${orderByQuery}
`;
const result = await queryClickhouse<{
start_time: string;
costs: Array<[string, number | null]>;
}>({
query,
params: {
projectId,
...appliedFilter.params,
...environmentFilter.params,
...orderByParams,
...(timeFilter
? { traceTimestamp: convertDateToClickhouseDateTime(timeFilter.value) }
: {}),
},
tags: {
feature: "dashboard",
type: "observationCostByTypeByTime",
kind: "analytic",
projectId,
},
});
const types = result.flatMap((row) => {
return row.costs.map((cost) => cost[0]);
});
const uniqueTypes = [...new Set(types)];
return result.flatMap((row) => {
const intervalStart = parseClickhouseUTCDateTimeFormat(row.start_time);
return uniqueTypes.map((type) => ({
intervalStart: intervalStart,
key: type,
sum: row.costs.find((cost) => cost[0] === type)?.[1]
? Number(row.costs.find((cost) => cost[0] === type)?.[1])
: 0,
}));
});
};
export const getObservationUsageByTypeByTime = async (
projectId: string,
filter: FilterState,
) => {
const { envFilter, remainingFilters } =
extractEnvironmentFilterFromFilters(filter);
const environmentFilter = new FilterList(
convertEnvFilterToClickhouseFilter(envFilter),
).apply();
const chFilter = new FilterList(
createFilterFromFilterState(remainingFilters, dashboardColumnDefinitions),
);
const appliedFilter = chFilter.apply();
const tracesFilter = chFilter.find((f) => f.clickhouseTable === "traces");
const timeFilter = tracesFilter
? (chFilter.find(
(f) =>
f.clickhouseTable === "observations" &&
f.field.includes("start_time") &&
(f.operator === ">=" || f.operator === ">"),
) as DateTimeFilter | undefined)
: undefined;
const [orderByQuery, orderByParams, bucketSizeInSeconds] = orderByTimeSeries(
filter,
"start_time",
);
const query = `
SELECT
start_time,
groupArray((usage_key, usage_sum)) AS usages
FROM (
SELECT
${selectTimeseriesColumn(bucketSizeInSeconds, "start_time", "start_time")} ,
usage_key,
SUM(usage) AS usage_sum
FROM
observations o FINAL
${tracesFilter ? "LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id" : ""}
ARRAY JOIN
mapKeys(usage_details) AS usage_key,
mapValues(usage_details) AS usage
WHERE project_id = {projectId: String}
AND ${appliedFilter.query}
${environmentFilter.query ? `AND ${environmentFilter.query}` : ""}
${timeFilter ? `AND t.timestamp >= {traceTimestamp: DateTime64(3)} - ${OBSERVATIONS_TO_TRACE_INTERVAL}` : ""}
GROUP BY
start_time,
usage_key
)
GROUP BY
start_time
${orderByQuery}
`;
const result = await queryClickhouse<{
start_time: string;
usages: Array<[string, number | null]>;
}>({
query,
params: {
projectId,
...appliedFilter.params,
...environmentFilter.params,
...orderByParams,
...(timeFilter
? { traceTimestamp: convertDateToClickhouseDateTime(timeFilter.value) }
: {}),
},
tags: {
feature: "dashboard",
type: "observationUsageByTime",
kind: "analytic",
projectId,
},
});
const types = result.flatMap((row) => {
return row.usages.map((usage) => usage[0]);
});
const uniqueTypes = [...new Set(types)];
return result.flatMap((row) => {
const intervalStart = parseClickhouseUTCDateTimeFormat(row.start_time);
return uniqueTypes.map((type) => ({
intervalStart: intervalStart,
key: type,
sum: row.usages.find((usage) => usage[0] === type)?.[1]
? Number(row.usages.find((usage) => usage[0] === type)?.[1])
: 0,
}));
});
};
export const getDistinctModels = async (
projectId: string,
filter: FilterState,
@@ -9,7 +9,7 @@ type FetchDatasetItemsTableProps = {
filter: FilterState;
};
const getDatasetItemsTableGeneric = async <T>(
const getDatasetRunItemsTableGeneric = async <T>(
props: FetchDatasetItemsTableProps,
) => {
const { select, projectId, filter } = props;
@@ -34,10 +34,10 @@ const getDatasetItemsTableGeneric = async <T>(
const query = Prisma.sql`
SELECT
${sqlSelect}
FROM dataset_items di
WHERE
di.project_id = ${projectId}
${datasetItemsFilter}
FROM dataset_run_items as dri
JOIN dataset_items as di ON di.id = dri.dataset_item_id AND di.project_id = ${projectId}
WHERE dri.project_id = ${projectId}
${datasetItemsFilter}
`;
const res = await prisma.$queryRaw<T>(query);
@@ -45,11 +45,11 @@ const getDatasetItemsTableGeneric = async <T>(
return res;
};
export const getDatasetItemsTableCount = async (props: {
export const getDatasetRunItemsTableCount = async (props: {
projectId: string;
filter: FilterState;
}) => {
const res = await getDatasetItemsTableGeneric<Array<{ count: bigint }>>({
const res = await getDatasetRunItemsTableGeneric<Array<{ count: bigint }>>({
select: "count",
projectId: props.projectId,
filter: props.filter,
@@ -1,5 +1,7 @@
import { UiColumnMappings } from "./types";
// Make sure to update web/src/features/query/dashboardUiTableToViewMapping.ts if you make changes
export const dashboardColumnDefinitions: UiColumnMappings = [
{
uiTableName: "Trace Name",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.45.0",
"version": "3.45.2",
"private": true,
"license": "MIT",
"engines": {
@@ -1,6 +1,6 @@
import {
createOrgProjectAndApiKey,
getDatasetItemsTableCount,
getDatasetRunItemsTableCount,
} from "@langfuse/shared/src/server";
import { v4 as uuidv4 } from "uuid";
import { prisma } from "@langfuse/shared/src/db";
@@ -23,6 +23,8 @@ describe("trpc.datasets", () => {
beforeAll(async () => {
const { projectId: newProjectId } = await createOrgProjectAndApiKey();
const datasetItemIds = [uuidv4(), uuidv4()];
const datasetRunIds = [uuidv4(), uuidv4()];
projectId = newProjectId;
datasetIds = [uuidv4(), uuidv4()];
@@ -35,16 +37,35 @@ describe("trpc.datasets", () => {
});
await prisma.datasetItem.createMany({
data: datasetIds.map((datasetId) => ({
id: uuidv4(),
data: datasetIds.map((datasetId, index) => ({
id: datasetItemIds[index],
projectId: projectId,
datasetId: datasetId,
})),
});
await prisma.datasetRuns.createMany({
data: datasetRunIds.map((datasetRunId, index) => ({
id: datasetRunId,
projectId: projectId,
datasetId: datasetIds[index],
name: `test-${index}`,
})),
});
await prisma.datasetRunItems.createMany({
data: datasetItemIds.map((datasetItemId, index) => ({
id: uuidv4(),
projectId: projectId,
datasetItemId: datasetItemId,
traceId: uuidv4(),
datasetRunId: datasetRunIds[index],
})),
});
});
describe("GET datasetItems.countAll", () => {
it("should GET all dataset items with no filter", async () => {
const { totalCount } = await getDatasetItemsTableCount({
it("should GET all dataset run items with no filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCount({
projectId: projectId,
filter: [],
});
@@ -52,8 +73,8 @@ describe("trpc.datasets", () => {
expect(totalCount).toBe(2);
});
it("should GET all dataset items with filter", async () => {
const { totalCount } = await getDatasetItemsTableCount({
it("should GET all dataset run items with filter", async () => {
const { totalCount } = await getDatasetRunItemsTableCount({
projectId: projectId,
filter: generateFilter([datasetIds[0]]),
});
@@ -1,5 +1,12 @@
import { prepareUsageDataForTimeseriesChart } from "@/src/features/dashboard/components/ModelUsageChart";
import { orderByTimeSeries } from "@langfuse/shared/src/server";
import {
orderByTimeSeries,
getObservationUsageByTypeByTime,
createOrgProjectAndApiKey,
createTrace,
createTracesCh,
createObservationsCh,
createObservation,
} from "@langfuse/shared/src/server";
describe("orderByTimeSeries", () => {
it("should return correct bucket size and query for 1 hour time range", () => {
@@ -89,55 +96,102 @@ describe("orderByTimeSeries", () => {
);
});
describe("aggregate time series for model cost and usage", () => {
it("should aggregate time series for model cost and usage", async () => {
const metricHistory = prepareUsageDataForTimeseriesChart(
["gpt-4o-mini", "text-embedding-ada-002"],
[
{
startTime: "2025-02-10T13:30:00.000Z",
units: {
input: 422,
output: 61,
total: 483,
},
cost: {
input: 0.0000633,
output: 0.0000366,
total: 0.0000999,
},
model: "gpt-4o-mini",
},
{
startTime: "2025-02-10T13:30:00.000Z",
units: {
input: 6,
total: 6,
},
cost: {
total: 6e-7,
},
model: "text-embedding-ada-002",
},
],
describe("getObservationUsageByTypeByTime", () => {
const mockFilter = [
{
type: "datetime" as const,
column: "timestamp",
operator: ">=" as const,
value: new Date("2024-01-01T00:00:00Z"),
},
{
type: "datetime" as const,
column: "timestamp",
operator: "<=" as const,
value: new Date("2024-01-02T01:00:00Z"),
},
];
it("should return usage data grouped by time and type", async () => {
const { projectId } = await createOrgProjectAndApiKey();
const trace = createTrace({
name: "trace-name",
project_id: projectId,
timestamp: new Date("2024-01-01T01:00:00Z").getTime(),
});
const trace2 = createTrace({
name: "trace-name",
project_id: projectId,
timestamp: new Date("2024-01-01T04:00:00Z").getTime(),
});
await createTracesCh([trace, trace2]);
const obs1 = createObservation({
trace_id: trace.id,
project_id: trace.project_id,
usage_details: { input: 1, output: 2, total: 3 },
start_time: new Date("2024-01-01T01:00:00Z").getTime(),
});
const obs2 = createObservation({
trace_id: trace.id,
project_id: trace.project_id,
usage_details: { input: 4, output: 5, total: 9 },
start_time: new Date("2024-01-01T01:00:00Z").getTime(),
});
const obs3 = createObservation({
trace_id: trace2.id,
project_id: trace.project_id,
usage_details: { input: 400, output: 500, total: 900 },
start_time: new Date("2024-01-01T04:00:00Z").getTime(),
});
await createObservationsCh([obs1, obs2, obs3]);
const result = await getObservationUsageByTypeByTime(
projectId,
mockFilter,
);
expect(metricHistory.get("total")).toEqual([
{
startTime: "2025-02-10T13:30:00.000Z",
units: 483,
cost: 0.0000999,
model: "gpt-4o-mini",
usageType: "total",
},
{
startTime: "2025-02-10T13:30:00.000Z",
units: 6,
cost: 6e-7,
model: "text-embedding-ada-002",
usageType: "total",
},
]);
// Verify the structure of the returned data
expect(result).toEqual(
expect.arrayContaining([
{
intervalStart: new Date("2024-01-01T01:00:00Z"),
key: "input",
sum: 5,
},
{
intervalStart: new Date("2024-01-01T01:00:00Z"),
key: "output",
sum: 7,
},
{
intervalStart: new Date("2024-01-01T01:00:00Z"),
key: "total",
sum: 12,
},
{
intervalStart: new Date("2024-01-01T04:00:00Z"),
key: "input",
sum: 400,
},
{
intervalStart: new Date("2024-01-01T04:00:00Z"),
key: "output",
sum: 500,
},
{
intervalStart: new Date("2024-01-01T04:00:00Z"),
key: "total",
sum: 900,
},
]),
);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,903 @@
import { randomUUID } from "crypto";
import {
createTrace,
createTracesCh,
createObservation,
createObservationsCh,
getTotalTraces,
getTracesGroupedByName,
getObservationsCostGroupedByName,
getScoreAggregate,
groupTracesByTime,
} from "@langfuse/shared/src/server";
import { type FilterState } from "@langfuse/shared";
import { type QueryType } from "@/src/features/query/types";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
import { dashboardColumnDefinitions } from "@langfuse/shared";
/**
* Test suite for testing the self-serve dashboards functionality
* This tests that the new query builder produces the same results as the existing dashboard queries
*/
describe("selfServeDashboards", () => {
// Single project ID for all tests
const projectId = randomUUID();
// Time references
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600000);
const twoHoursAgo = new Date(now.getTime() - 7200000);
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
// Time ranges for queries - converted to ClickHouse DateTime format (YYYY-MM-DD HH:MM:SS.SSS)
const defaultFromTime = threeDaysAgo.toISOString();
const defaultToTime = new Date(now.getTime() + 3600000).toISOString(); // 1 hour in future
// Test data statistics for verification
const stats = {
totalTraces: 0,
productionTraces: 0,
developmentTraces: 0,
stagingTraces: 0,
recentProductionTraces: 0, // within the last hour
traceCounts: {} as Record<string, number>, // counts by trace name
environmentCounts: {} as Record<string, number>, // counts by environment
};
beforeAll(async () => {
// Create a diverse set of traces with different characteristics
const traces = [
// Production environment - common names
...Array(5)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "production",
timestamp: now.getTime() - i * 10000, // Slightly different timestamps
user_id: "user-A",
}),
),
...Array(3)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "embeddings",
environment: "production",
timestamp: now.getTime() - i * 15000,
user_id: "user-B",
}),
),
// Production environment - older traces
...Array(2)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "production",
timestamp: twoHoursAgo.getTime() - i * 10000,
user_id: "user-C",
}),
),
// Development environment - recent
...Array(4)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "chat-completion",
environment: "development",
timestamp: oneHourAgo.getTime() - i * 20000,
user_id: "user-D",
}),
),
...Array(2)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "summarize",
environment: "development",
timestamp: now.getTime() - i * 5000,
user_id: "user-E",
}),
),
// Staging environment
...Array(3)
.fill(0)
.map((_, i) =>
createTrace({
project_id: projectId,
name: "qa-bot",
environment: "staging",
timestamp: now.getTime() - i * 30000,
user_id: "user-F",
}),
),
];
// Insert traces into ClickHouse
await createTracesCh(traces);
// Create observations for some of these traces
const observations = [];
// Add observations for chat-completion traces in production
for (let i = 0; i < 3; i++) {
const traceId = traces[i].id;
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "gpt-4-turbo",
type: "generation",
environment: "production",
start_time: now.getTime() - i * 10000,
completion_start_time: now.getTime() - i * 10000 + 800, // 800ms time to first token
end_time: now.getTime() - i * 10000 + 3000, // 3000ms total duration
provided_model_name: "gpt-4-turbo",
}),
);
}
// Add observations for embeddings traces
for (let i = 0; i < 2; i++) {
const traceId = traces[5 + i].id; // embeddings traces start at index 5
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "text-embedding-ada-002",
type: "generation",
environment: "production",
start_time: now.getTime() - i * 15000,
completion_start_time: now.getTime() - i * 15000 + 200, // 200ms time to first token
end_time: now.getTime() - i * 15000 + 500, // 500ms total duration
provided_model_name: "text-embedding-ada-002",
}),
);
}
// Add observations for development traces
for (let i = 0; i < 2; i++) {
const traceId = traces[10 + i].id; // development traces start at index 10
observations.push(
createObservation({
project_id: projectId,
trace_id: traceId,
name: "claude-3-opus",
type: "generation",
environment: "development",
start_time: oneHourAgo.getTime() - i * 20000,
completion_start_time: oneHourAgo.getTime() - i * 20000 + 1200, // 1200ms time to first token
end_time: oneHourAgo.getTime() - i * 20000 + 4000, // 4000ms total duration
provided_model_name: "claude-3-opus",
}),
);
}
// Insert observations into ClickHouse
await createObservationsCh(observations);
// Calculate statistics for verification
stats.totalTraces = traces.length;
// Count by environment
traces.forEach((trace) => {
stats.environmentCounts[trace.environment] =
(stats.environmentCounts[trace.environment] || 0) + 1;
});
stats.productionTraces = stats.environmentCounts["production"] || 0;
stats.developmentTraces = stats.environmentCounts["development"] || 0;
stats.stagingTraces = stats.environmentCounts["staging"] || 0;
// Count traces by name
traces.forEach((trace) => {
stats.traceCounts[trace.name || ""] =
(stats.traceCounts[trace.name || ""] || 0) + 1;
});
// Count recent production traces (within the last hour)
stats.recentProductionTraces = traces.filter(
(t) =>
t.environment === "production" && t.timestamp >= oneHourAgo.getTime(),
).length;
});
describe("traces-total query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// Empty filter for this test
const filter: FilterState = [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
];
// 1. Get result using the legacy function
const legacyResult = await getTotalTraces(projectId, filter);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Assert that both results match
expect(queryBuilderResult).toHaveLength(1);
expect(Number(queryBuilderResult[0].count_count)).toBe(stats.totalTraces);
expect(Number(legacyResult?.[0]?.countTraceId)).toBe(stats.totalTraces);
});
it("should filter traces by environment correctly", async () => {
// 1. Define a filter for production environment in the legacy format
const prodLegacyFilter: FilterState = [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
];
const prodLegacyResult = await getTotalTraces(
projectId,
prodLegacyFilter,
);
// 2. Define the equivalent query with filter for the query builder
const prodQueryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
const prodQueryBuilderResult = await executeQuery(
projectId,
prodQueryBuilderQuery,
);
// 3. Assert that both results match and only count production traces
expect(prodQueryBuilderResult).toHaveLength(1);
expect(Number(prodQueryBuilderResult[0].count_count)).toBe(
stats.productionTraces,
);
expect(Number(prodLegacyResult?.[0]?.countTraceId)).toBe(
stats.productionTraces,
);
// 4. Test another filter for development environment
const devLegacyFilter: FilterState = [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
{
type: "string",
operator: "=",
column: "environment",
value: "development",
},
];
const devLegacyResult = await getTotalTraces(projectId, devLegacyFilter);
const devQueryBuilderQuery: QueryType = {
...prodQueryBuilderQuery,
filters: [
{
column: "environment",
operator: "=",
value: "development",
type: "string",
},
],
};
const devQueryBuilderResult = await executeQuery(
projectId,
devQueryBuilderQuery,
);
// 5. Assert development environment results
expect(devQueryBuilderResult).toHaveLength(1);
expect(Number(devQueryBuilderResult[0].count_count)).toBe(
stats.developmentTraces,
);
expect(Number(devLegacyResult?.[0]?.countTraceId)).toBe(
stats.developmentTraces,
);
});
it("should handle multiple filter conditions", async () => {
// 1. Define a filter for recent production traces in the legacy format
const recentProdLegacyFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: oneHourAgo,
},
];
const legacyResult = await getTotalTraces(
projectId,
recentProdLegacyFilter,
);
// 2. Define the equivalent query with multiple filters for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
{
column: "timestamp",
operator: ">=",
value: new Date(oneHourAgo),
type: "datetime",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 3. Assert that both results match and only count recent production traces
expect(queryBuilderResult).toHaveLength(1);
expect(Number(queryBuilderResult[0].count_count)).toBe(
stats.recentProductionTraces,
);
expect(legacyResult?.[0]?.countTraceId).toBe(
`${stats.recentProductionTraces}`,
);
});
});
describe("traces-grouped-by-name query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await getTracesGroupedByName(
projectId,
dashboardColumnDefinitions,
[], // empty filter
);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Verify both results
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item.count]),
);
// Verify results match the expected trace counts by name
Object.keys(stats.traceCounts).forEach((traceName) => {
const countFromLegacy = legacyResultMap.get(traceName);
const resultRow = queryBuilderResult.find(
(row: any) => row.name === traceName,
);
expect(countFromLegacy).toBe(`${stats.traceCounts[traceName]}`);
expect(Number(resultRow?.count_count)).toBe(
stats.traceCounts[traceName],
);
});
// Verify both result sets have the same number of rows
expect(legacyResult.length).toBe(Object.keys(stats.traceCounts).length);
expect(queryBuilderResult.length).toBe(
Object.keys(stats.traceCounts).length,
);
});
it("should filter traces by environment when grouping by name", async () => {
// 1. Define a filter for production environment
const prodFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
];
// 2. Get legacy result with filter
const legacyResult = await getTracesGroupedByName(
projectId,
dashboardColumnDefinitions,
prodFilter,
);
// 3. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 4. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 5. Verify results
// Get only traces with production environment
const productionTraceNames = ["chat-completion", "embeddings"];
// Verify both results have the expected number of rows
expect(legacyResult.length).toBe(productionTraceNames.length);
expect(queryBuilderResult.length).toBe(productionTraceNames.length);
// Create easy-to-use maps for comparison
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item.count]),
);
// Check each production trace name is present with correct count
productionTraceNames.forEach((traceName) => {
const countFromLegacy = legacyResultMap.get(traceName);
const resultRow = queryBuilderResult.find(
(row: any) => row.name === traceName,
);
expect(countFromLegacy).toBeDefined();
expect(resultRow).toBeDefined();
expect(resultRow?.count_count).toBe(countFromLegacy);
});
});
});
describe("observations-model-cost query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await getObservationsCostGroupedByName(
projectId,
[], // empty filter
);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "observations",
dimensions: [{ field: "providedModelName" }],
metrics: [
{ measure: "totalCost", aggregation: "sum" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Verify both results
expect(queryBuilderResult).toBeDefined();
expect(legacyResult).toBeDefined();
// Create maps for easier comparison
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item]),
);
// Verify each model's costs and token usage match
queryBuilderResult.forEach((row: any) => {
const modelName = row.provided_model_name;
const legacyModelData = legacyResultMap.get(modelName);
expect(legacyModelData).toBeDefined();
expect(row.sum_total_cost).toBe(legacyModelData?.sum_cost_details);
expect(row.sum_total_tokens).toBe(legacyModelData?.sum_usage_details);
});
// Verify both result sets have the same number of models
expect(legacyResult.length).toBe(queryBuilderResult.length);
});
it("should filter observations by environment", async () => {
// 1. Define a filter for production environment
const prodFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
];
// 2. Get legacy result with filter
const legacyResult = await getObservationsCostGroupedByName(
projectId,
prodFilter,
);
// 3. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "observations",
dimensions: [{ field: "providedModelName" }],
metrics: [
{ measure: "totalCost", aggregation: "sum" },
{ measure: "totalTokens", aggregation: "sum" },
],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 4. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 5. Verify results
// Production models should only include gpt-4-turbo and text-embedding-ada-002
const productionModels = ["gpt-4-turbo", "text-embedding-ada-002"];
// Verify both results have the expected number of rows
expect(legacyResult.length).toBe(productionModels.length);
expect(queryBuilderResult.length).toBe(productionModels.length);
// Create maps for easier comparison
const legacyResultMap = new Map(
legacyResult.map((item) => [item.name, item]),
);
// Verify each production model is present with correct costs
queryBuilderResult.forEach((row: any) => {
const modelName = row.provided_model_name;
const legacyModelData = legacyResultMap.get(modelName);
expect(productionModels).toContain(modelName);
expect(legacyModelData).toBeDefined();
expect(row.sum_total_cost).toBe(legacyModelData?.sum_cost_details);
expect(row.sum_total_tokens).toBe(legacyModelData?.sum_usage_details);
});
});
});
describe("score-aggregate query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await getScoreAggregate(projectId, [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
]);
// 2. Define the equivalent query for the query builder
const queryBuilderNumericQuery: QueryType = {
view: "scores-numeric",
dimensions: [
{ field: "name" },
{ field: "source" },
{ field: "dataType" },
],
metrics: [
{ measure: "value", aggregation: "avg" },
{ measure: "count", aggregation: "count" },
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get results using the query builder for numeric scores
const queryBuilderNumericResult = await executeQuery(
projectId,
queryBuilderNumericQuery,
);
// 4. Check categorical scores separately (optional, as the test dataset may not include them)
const queryCategoricalQuery: QueryType = {
view: "scores-categorical",
dimensions: [
{ field: "name" },
{ field: "source" },
{ field: "dataType" },
],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Get results for categorical scores
const queryBuilderCatResult = await executeQuery(
projectId,
queryCategoricalQuery,
);
// 5. Verify both results
expect(queryBuilderNumericResult).toBeDefined();
expect(legacyResult).toBeDefined();
// Check that all numeric scores from legacy query are present in new query
// Note: This test assumes numeric scores. If you have categorical scores, you'd need to
// handle them separately by checking against queryBuilderCatResult
legacyResult.forEach((legacyScore) => {
// Only check numeric scores here
if (legacyScore.data_type === "numeric") {
const matchingRow = queryBuilderNumericResult.find(
(row: any) =>
row.name === legacyScore.name &&
row.source === legacyScore.source &&
row.data_type === legacyScore.data_type,
);
expect(matchingRow).toBeDefined();
// Check count matches
expect(Number(matchingRow?.count_count)).toBe(
Number(legacyScore.count),
);
// Check average value is approximately the same
expect(Number(matchingRow?.value_avg)).toBeCloseTo(
Number(legacyScore.avg_value),
);
}
});
// If we have categorical scores in our test data, verify those too
const categoricalScores = legacyResult.filter(
(score) => score.data_type === "categorical",
);
if (categoricalScores.length > 0 && queryBuilderCatResult.length > 0) {
categoricalScores.forEach((legacyScore) => {
const matchingRow = queryBuilderCatResult.find(
(row: any) =>
row.name === legacyScore.name &&
row.source === legacyScore.source &&
row.data_type === legacyScore.data_type,
);
expect(matchingRow).toBeDefined();
// Check count matches
expect(Number(matchingRow?.count_count)).toBe(
Number(legacyScore.count),
);
});
}
});
it("should filter scores by environment", async () => {
// 1. Define a filter for production environment
const prodFilter: FilterState = [
{
type: "string",
operator: "=",
column: "environment",
value: "production",
},
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date("1970-01-02"),
},
];
// 2. Get legacy result with filter
const legacyResult = await getScoreAggregate(projectId, prodFilter);
// 3. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "scores-numeric", // We'll just test numeric scores for simplicity
dimensions: [
{ field: "name" },
{ field: "source" },
{ field: "dataType" },
],
metrics: [
{ measure: "value", aggregation: "avg" },
{ measure: "count", aggregation: "count" },
],
filters: [
{
column: "environment",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 4. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 5. Verify results
// Production environment should only include certain scores (based on test data)
// We can check if both results have the same number of scores for production
expect(queryBuilderResult.length).toBe(
legacyResult.filter((score) => score.data_type === "numeric").length,
);
// Check that all numeric scores in production environment from legacy query match the new query
legacyResult
.filter((score) => score.data_type === "numeric")
.forEach((legacyScore) => {
const matchingRow = queryBuilderResult.find(
(row: any) =>
row.name === legacyScore.name &&
row.source === legacyScore.source &&
row.data_type === legacyScore.data_type,
);
expect(matchingRow).toBeDefined();
// Check count matches
expect(Number(matchingRow?.count_count)).toBe(
Number(legacyScore.count),
);
// Check average value is approximately the same
expect(Number(matchingRow?.value_avg)).toBeCloseTo(
Number(legacyScore.avg_value),
);
});
});
});
describe("traces-timeseries query", () => {
it("should return the same result with query builder as with legacy function", async () => {
// 1. Get result using the legacy function
const legacyResult = await groupTracesByTime(projectId, [
{
type: "datetime",
operator: ">=",
column: "timestamp",
value: new Date(defaultFromTime),
},
{
type: "datetime",
operator: "<=",
column: "timestamp",
value: new Date(defaultToTime),
},
]);
// 2. Define the equivalent query for the query builder
const queryBuilderQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "hour",
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// 3. Get result using the query builder
const queryBuilderResult = await executeQuery(
projectId,
queryBuilderQuery,
);
// 4. Verify both results
// Both results should be between 70 and 80 rows
expect(legacyResult.length).toBeGreaterThanOrEqual(70);
expect(legacyResult.length).toBeLessThanOrEqual(80);
expect(queryBuilderResult.length).toBeGreaterThanOrEqual(70);
expect(queryBuilderResult.length).toBeLessThanOrEqual(80);
// Compare the non-zero results to each other. The legacy setup should have more empty records in the future
// so the indexing should match up.
legacyResult.forEach((result, index) => {
if (result.countTraceId > 0) {
const queryBuilderRow = queryBuilderResult[index];
expect(queryBuilderRow).toBeDefined();
expect(Number(queryBuilderRow.count_count)).toBe(result.countTraceId);
}
});
});
});
});
@@ -0,0 +1,458 @@
import { randomUUID } from "crypto";
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
import { type QueryType } from "@/src/features/query/types";
import { TRPCError } from "@trpc/server";
import { executeQuery } from "@/src/features/dashboard/server/dashboard-router";
/**
* Test suite for testing SQL injection vulnerabilities in the QueryBuilder
*/
describe("QueryBuilder SQL Injection Tests", () => {
// Single project ID for all tests
const projectId = randomUUID();
// Time references
const now = new Date();
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 3600000);
// Time ranges for queries - converted to ClickHouse DateTime format
const defaultFromTime = threeDaysAgo.toISOString();
const defaultToTime = now.toISOString();
// Create a mock ClickHouse client for testing
const mockClickhouseClient = {
query: jest.fn().mockImplementation(({ query, query_params }) => {
// Return the query and params for inspection in tests
return Promise.resolve({
json: jest.fn().mockReturnValue({
data: [],
query,
params: query_params,
}),
});
}),
};
// Helper function to build a query without executing it
const buildQueryWithoutExecuting = (query: QueryType, projectId: string) => {
const queryBuilder = new QueryBuilder(mockClickhouseClient as any);
return queryBuilder.build(query, projectId);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("SQL Injection via View Parameter", () => {
it("should prevent injection via invalid view name", async () => {
// Comment: The view property is restricted to specific enum values,
// but a determined attacker might try to bypass zod validation or
// supply a maliciously crafted view name
const maliciousQuery = {
view: "traces; DROP TABLE users" as any,
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
};
// Should throw an error rather than allow the injection
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Dimension Fields", () => {
it("should prevent injection via dimension field name", async () => {
// Comment: The field names in dimensions should be validated against allowed fields
// in the view declaration. This test checks if an attacker can inject arbitrary SQL
// by manipulating the dimension field name.
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name; DROP TABLE traces; --" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid dimension
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid dimension");
});
it("should safely handle special characters in valid dimension fields", async () => {
// Comment: Even with valid fields, we need to ensure special characters
// don't lead to injections when building the SQL query
const query: QueryType = {
view: "traces",
dimensions: [{ field: "name" }], // Valid field
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "name",
operator: "=",
value: "chat'; DROP TABLE traces; --", // SQL injection in value
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should build a valid query using parameterized queries for safety
const result = buildQueryWithoutExecuting(query, projectId);
// Ensure the value is parameterized and not directly included in the SQL
expect(result.query).not.toContain("chat'; DROP TABLE traces; --");
expect(Object.values(result.parameters)).toContain(
"chat'; DROP TABLE traces; --",
);
});
});
describe("SQL Injection via Metrics", () => {
it("should prevent injection via metric measure name", async () => {
// Comment: Similar to dimensions, metrics should be validated against allowed measures
// This test checks if an attacker can inject SQL via the measure property
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [
{
measure: "count); DROP TABLE traces; --",
aggregation: "count",
},
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid metric
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid metric");
});
it("should prevent injection via metric aggregation", async () => {
// Comment: The aggregation function could be another injection vector if
// not properly validated
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [
{
measure: "count",
aggregation: "count); DROP TABLE traces; --" as any,
},
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid aggregation
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Filters", () => {
it("should prevent injection via filter field name", async () => {
// Comment: Filter field names must be validated like dimensions
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment); DROP TABLE traces; --",
operator: "=",
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid filter field
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid filter");
});
it("should prevent injection via filter operator", async () => {
// Comment: Filter operators should be validated against a list of allowed operators
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=; DROP TABLE traces; --" as any,
value: "production",
type: "string",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid operator
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
it("should safely handle special characters in filter values", async () => {
// Comment: Filter values should be parameterized to prevent injection
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "=",
type: "string",
value: "production'; DROP TABLE traces; --",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should build a valid query with parameterization
const result = buildQueryWithoutExecuting(query, projectId);
// Check for parameterization
expect(result.query).not.toContain("production'; DROP TABLE traces; --");
expect(Object.values(result.parameters)).toContain(
"production'; DROP TABLE traces; --",
);
});
it("should safely handle array values in IN operators", async () => {
// Comment: IN operators with arrays need special handling for SQL injection prevention
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [
{
column: "environment",
operator: "any of",
value: ["production", "development'); DROP TABLE traces; --"],
type: "stringOptions",
},
],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should parameterize the values
const result = buildQueryWithoutExecuting(query, projectId);
expect(result.query).not.toContain(
"production,development'); DROP TABLE traces; --",
);
});
});
describe("SQL Injection via Time Dimension", () => {
it("should prevent injection via time dimension granularity", async () => {
// Comment: Time dimension granularity should be validated against allowed values
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: {
granularity: "minute; DROP TABLE traces; --" as any,
},
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid granularity
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Timestamp Parameters", () => {
it("should safely handle malicious timestamp strings", async () => {
// Comment: Timestamps need to be properly validated and converted
// to prevent SQL injection
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: "2023-01-01'); DROP TABLE traces; --",
toTimestamp: defaultToTime,
orderBy: null,
};
// Should throw an error for invalid timestamp format
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
});
describe("SQL Injection via Project ID", () => {
it("should safely handle malicious project ID", async () => {
// Comment: Project ID is a critical parameter that must be properly sanitized
const query: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
const maliciousProjectId = "fake-id'; DROP TABLE traces; --";
// Should parameterize the project ID
const result = buildQueryWithoutExecuting(query, maliciousProjectId);
// Check for parameterization
expect(result.query).not.toContain(maliciousProjectId);
expect(Object.values(result.parameters)).toContain(maliciousProjectId);
});
});
describe("SQL Injection via OrderBy Parameters", () => {
it("should prevent injection via orderBy field name", async () => {
// Comment: The field names in orderBy should be validated against dimension and metric fields
// to prevent SQL injection via field name
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{
field: "name; DROP TABLE traces; --",
direction: "asc",
},
],
};
// Should throw an error for invalid orderBy field
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid orderBy field");
});
it("should prevent injection via orderBy direction", async () => {
// Comment: The direction value should be validated to prevent SQL injection
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{
field: "name",
direction: "asc; DROP TABLE traces; --" as any,
},
],
};
// Should throw an error for invalid direction
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid query");
});
it("should prevent injection via non-existing metric field in orderBy", async () => {
// Comment: The field must exist as a metric with proper aggregation prefix
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: [
{
field: "sum_malicious_metric; DROP TABLE traces; --",
direction: "asc",
},
],
};
// Should throw an error for invalid orderBy field
expect(() =>
buildQueryWithoutExecuting(maliciousQuery, projectId),
).toThrow("Invalid orderBy field");
});
});
describe("Integration with executeQuery function", () => {
it("should safely handle malicious query parameters through executeQuery", async () => {
// Comment: This tests the integration with the dashboard router's executeQuery function
// to ensure SQL injection protection works end-to-end
jest.spyOn(console, "error").mockImplementation(() => {});
const maliciousQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [
{
measure: "count); DELETE FROM traces; --" as any,
aggregation: "count",
},
],
filters: [],
timeDimension: null,
fromTimestamp: defaultFromTime,
toTimestamp: defaultToTime,
orderBy: null,
};
// Expect the executeQuery function to throw a TRPC error
// rather than allowing the injection
await expect(executeQuery(projectId, maliciousQuery)).rejects.toThrow(
TRPCError,
);
});
});
});
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.45.0";
export const VERSION = "v3.45.2";
@@ -31,8 +31,8 @@ export function TimeScopeDescription(props: {
: props.timeScope?.includes("NEW")
? "all future"
: "all existing"}{" "}
{props.target === "trace" ? "traces" : "dataset items"} that match these
filters.{" "}
{props.target === "trace" ? "traces" : "dataset run items"} that match
these filters.{" "}
</div>
);
}
@@ -78,7 +78,7 @@ export const ExecutionCountTooltip = ({
: globalConfig.data,
)
)}{" "}
{isTraceTarget(item) ? "traces" : "dataset items"}.
{isTraceTarget(item) ? "traces" : "dataset run items"}.
</div>
</TooltipContent>
</Tooltip>
@@ -390,7 +390,7 @@ export const InnerEvaluatorForm = (props: {
New{" "}
{form.watch("target") === "trace"
? "traces"
: "dataset items"}
: "dataset run items"}
</label>
</div>
</div>
@@ -418,9 +418,10 @@ export const InnerEvaluatorForm = (props: {
Existing{" "}
{form.watch("target") === "trace"
? "traces"
: "dataset items"}
: "dataset run items"}
</label>
{field.value.includes("EXISTING") &&
props.mode !== "edit" &&
!props.disabled && (
<ExecutionCountTooltip
projectId={props.projectId}
@@ -22,13 +22,6 @@ import {
useModelSelection,
} from "@/src/features/dashboard/components/ModelSelector";
type ModelUsageReturnType = {
startTime: string;
units: Record<string, number>;
cost: Record<string, number>;
model: string;
};
export const ModelUsageChart = ({
className,
projectId,
@@ -86,7 +79,7 @@ export const ModelUsageChart = ({
orderBy: [
{ column: "calculatedTotalCost", direction: "DESC", agg: "SUM" },
],
queryName: "observations-usage-timeseries",
queryName: "observations-total-cost-by-model-timeseries",
},
{
enabled: !isLoading && selectedModels.length > 0 && allModels.length > 0,
@@ -98,86 +91,159 @@ export const ModelUsageChart = ({
},
);
const typedData = (queryResult.data as ModelUsageReturnType[]) ?? [];
const usageTypeMap = prepareUsageDataForTimeseriesChart(
selectedModels,
typedData,
const queryCostByType = api.dashboard.chart.useQuery(
{
projectId,
from: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION // Langfuse Cloud has already completed the cost backfill job, thus cost can be pulled directly from obs. table
? "traces_observations"
: "traces_observationsview",
select: [
{ column: "totalTokens", agg: "SUM" },
{ column: "calculatedTotalCost", agg: "SUM" },
{ column: "model" },
],
filter: [
...globalFilterState,
{ type: "string", column: "type", operator: "=", value: "GENERATION" },
{
type: "stringOptions",
column: "model",
operator: "any of",
value: selectedModels,
} as const,
],
groupBy: [
{
type: "datetime",
column: "startTime",
temporalUnit: dashboardDateRangeAggregationSettings[agg].date_trunc,
},
{
type: "string",
column: "model",
},
],
orderBy: [
{ column: "calculatedTotalCost", direction: "DESC", agg: "SUM" },
],
queryName: "observations-cost-by-type-timeseries",
},
{
enabled: !isLoading && selectedModels.length > 0 && allModels.length > 0,
trpc: {
context: {
skipBatch: true,
},
},
},
);
const usageData = Array.from(usageTypeMap.values()).flat();
const queryUsageByType = api.dashboard.chart.useQuery(
{
projectId,
from: env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION // Langfuse Cloud has already completed the cost backfill job, thus cost can be pulled directly from obs. table
? "traces_observations"
: "traces_observationsview",
select: [
{ column: "totalTokens", agg: "SUM" },
{ column: "calculatedTotalCost", agg: "SUM" },
{ column: "model" },
],
filter: [
...globalFilterState,
{ type: "string", column: "type", operator: "=", value: "GENERATION" },
{
type: "stringOptions",
column: "model",
operator: "any of",
value: selectedModels,
} as const,
],
groupBy: [
{
type: "datetime",
column: "startTime",
temporalUnit: dashboardDateRangeAggregationSettings[agg].date_trunc,
},
{
type: "string",
column: "model",
},
],
orderBy: [{ column: "totalTokens", direction: "DESC", agg: "SUM" }],
queryName: "observations-usage-by-type-timeseries",
},
{
enabled: !isLoading && selectedModels.length > 0 && allModels.length > 0,
trpc: {
context: {
skipBatch: true,
},
},
},
);
const currentModels = [
...new Set(usageData.map((row) => row.model).filter(Boolean)),
];
const unitsByType =
usageData && allModels.length > 0
const costByType =
queryCostByType.data && allModels.length > 0
? fillMissingValuesAndTransform(
extractTimeSeriesData(usageData, "startTime", [
extractTimeSeriesData(queryCostByType.data, "intervalStart", [
{
uniqueIdentifierColumns: [{ accessor: "usageType" }],
valueColumn: "units",
uniqueIdentifierColumns: [{ accessor: "key" }],
valueColumn: "sum",
},
]),
Array.from(usageTypeMap.keys()),
[],
)
: [];
const unitsByType =
queryUsageByType.data && allModels.length > 0
? fillMissingValuesAndTransform(
extractTimeSeriesData(queryUsageByType.data, "intervalStart", [
{
uniqueIdentifierColumns: [{ accessor: "key" }],
valueColumn: "sum",
},
]),
[],
)
: [];
const unitsByModel =
usageData && allModels.length > 0
queryResult.data && allModels.length > 0
? fillMissingValuesAndTransform(
extractTimeSeriesData(usageData, "startTime", [
extractTimeSeriesData(queryResult.data, "startTime", [
{
uniqueIdentifierColumns: [{ accessor: "model" }],
valueColumn: "units",
},
]),
currentModels,
)
: [];
const costByType =
usageData && allModels.length > 0
? fillMissingValuesAndTransform(
extractTimeSeriesData(usageData, "startTime", [
{
uniqueIdentifierColumns: [{ accessor: "usageType" }],
valueColumn: "cost",
},
]),
Array.from(usageTypeMap.keys()),
selectedModels,
)
: [];
const costByModel =
usageData && allModels.length > 0
queryResult.data && allModels.length > 0
? fillMissingValuesAndTransform(
extractTimeSeriesData(usageData, "startTime", [
extractTimeSeriesData(queryResult.data, "startTime", [
{
uniqueIdentifierColumns: [{ accessor: "model" }],
valueColumn: "cost",
},
]),
currentModels,
selectedModels,
)
: [];
const totalCost = usageData?.reduce(
const totalCost = queryResult.data?.reduce(
(acc, curr) =>
acc +
(curr.usageType === "total" && !isNaN(curr.cost as number)
? (curr.cost as number)
: 0),
acc + (!isNaN(curr.cost as number) ? (curr.cost as number) : 0),
0,
);
const totalTokens = usageData?.reduce(
const totalTokens = queryResult.data?.reduce(
(acc, curr) =>
acc +
(curr.usageType === "total" && !isNaN(curr.units as number)
? (curr.units as number)
: 0),
acc + (!isNaN(curr.units as number) ? (curr.units as number) : 0),
0,
);
@@ -274,63 +340,3 @@ export const ModelUsageChart = ({
</DashboardCard>
);
};
export function prepareUsageDataForTimeseriesChart(
selectedModels: string[],
typedData: ModelUsageReturnType[],
) {
const usageTypeMap = new Map<
string,
{
startTime: string;
units: number;
cost: number;
usageType: string;
model: string;
}[]
>();
const allUsageUnits = [
...new Set(typedData.flatMap((r) => Object.keys(r.units))),
];
const uniqueDates = [
...new Set(typedData.flatMap((r) => new Date(r.startTime).getTime())),
];
const uniqueModels = [...new Set(selectedModels)];
allUsageUnits.forEach((uu) => {
const unitEntries: {
startTime: string;
units: number;
cost: number;
usageType: string;
model: string;
}[] = [];
uniqueDates.forEach((d) => {
uniqueModels.forEach((m) => {
const existingEntry = typedData.find(
(td) =>
new Date(td.startTime).getTime() === new Date(d).getTime() &&
td.model === m,
);
const entry = {
startTime: new Date(d).toISOString(),
model: m,
units: existingEntry ? existingEntry.units[uu] || 0 : 0,
cost: existingEntry ? existingEntry.cost[uu] || 0 : 0,
usageType: uu,
};
unitEntries.push(entry);
});
});
usageTypeMap.set(uu, unitEntries);
});
return usageTypeMap;
}
@@ -7,30 +7,44 @@ import { TotalMetric } from "@/src/features/dashboard/components/TotalMetric";
import { BarList } from "@tremor/react";
import { compactNumberFormatter } from "@/src/utils/numbers";
import { NoDataOrLoading } from "@/src/components/NoDataOrLoading";
import {
type QueryType,
mapLegacyUiTableFilterToView,
} from "@/src/features/query";
export const TracesBarListChart = ({
className,
projectId,
globalFilterState,
fromTimestamp,
toTimestamp,
isLoading = false,
}: {
className?: string;
projectId: string;
globalFilterState: FilterState;
fromTimestamp: Date;
toTimestamp: Date;
isLoading?: boolean;
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const timeFilter = globalFilterState.map((f) =>
f.type === "datetime" ? { ...f, column: "timestamp" } : f,
);
const totalTraces = api.dashboard.chart.useQuery(
// Total traces query using executeQuery
const totalTracesQuery: QueryType = {
view: "traces",
dimensions: [],
metrics: [{ measure: "count", aggregation: "count" }],
filters: mapLegacyUiTableFilterToView("traces", globalFilterState),
timeDimension: null,
fromTimestamp: fromTimestamp.toISOString(),
toTimestamp: toTimestamp.toISOString(),
orderBy: null,
};
const totalTraces = api.dashboard.executeQuery.useQuery(
{
projectId,
from: "traces",
select: [{ column: "traceId", agg: "COUNT" }],
filter: timeFilter,
queryName: "traces-total",
query: totalTracesQuery,
},
{
trpc: {
@@ -42,15 +56,22 @@ export const TracesBarListChart = ({
},
);
const traces = api.dashboard.chart.useQuery(
// Traces grouped by name query using executeQuery
const tracesQuery: QueryType = {
view: "traces",
dimensions: [{ field: "name" }],
metrics: [{ measure: "count", aggregation: "count" }],
filters: mapLegacyUiTableFilterToView("traces", globalFilterState),
timeDimension: null,
fromTimestamp: fromTimestamp.toISOString(),
toTimestamp: toTimestamp.toISOString(),
orderBy: null,
};
const traces = api.dashboard.executeQuery.useQuery(
{
projectId,
from: "traces",
select: [{ column: "traceId", agg: "COUNT" }, { column: "traceName" }],
filter: timeFilter,
groupBy: [{ column: "traceName", type: "string" }],
orderBy: [{ column: "traceId", direction: "DESC", agg: "COUNT" }],
queryName: "traces-grouped-by-name",
query: tracesQuery,
},
{
trpc: {
@@ -62,14 +83,14 @@ export const TracesBarListChart = ({
},
);
const transformedTraces = traces.data
? traces.data.map((item) => {
return {
name: item.traceName ? (item.traceName as string) : "Unknown",
value: item.countTraceId as number,
};
})
: [];
// Transform the data to match the expected format for the BarList
const transformedTraces =
traces.data?.map((item: any) => {
return {
name: item.name ? (item.name as string) : "Unknown",
value: Number(item.count_count),
};
}) ?? [];
const maxNumberOfEntries = { collapsed: 5, expanded: 20 };
@@ -87,7 +108,9 @@ export const TracesBarListChart = ({
<>
<TotalMetric
metric={compactNumberFormatter(
totalTraces.data?.[0]?.countTraceId as number,
totalTraces.data?.[0]?.count_count
? Number(totalTraces.data[0].count_count)
: 0,
)}
description={"Total traces tracked"}
/>
@@ -15,7 +15,6 @@ import {
getTracesGroupedByName,
getObservationsCostGroupedByName,
getScoreAggregate,
getObservationUsageByTime,
groupTracesByTime,
getDistinctModels,
getScoresAggregateOverTime,
@@ -30,9 +29,18 @@ import {
getObservationsStatusTimeSeries,
extractFromAndToTimestampsFromFilter,
logger,
getTotalObservationUsageByTimeByModel,
getObservationCostByTypeByTime,
getObservationUsageByTypeByTime,
} from "@langfuse/shared/src/server";
import { type DatabaseRow } from "@/src/server/api/services/sqlInterface";
import { dashboardColumnDefinitions } from "@langfuse/shared";
import { QueryBuilder } from "@/src/features/query/server/queryBuilder";
import {
type QueryType,
query as customQuery,
} from "@/src/features/query/types";
import { clickhouseClient } from "@langfuse/shared/src/server";
export const dashboardRouter = createTRPCRouter({
chart: protectedProjectProcedure
@@ -47,7 +55,9 @@ export const dashboardRouter = createTRPCRouter({
"observations-model-cost",
"score-aggregate",
"traces-timeseries",
"observations-usage-timeseries",
"observations-total-cost-by-model-timeseries",
"observations-usage-by-type-timeseries",
"observations-cost-by-type-timeseries",
"distinct-models",
"scores-aggregate-timeseries",
"observations-usage-by-users",
@@ -125,22 +135,33 @@ export const dashboardRouter = createTRPCRouter({
);
return rows as DatabaseRow[];
case "observations-usage-timeseries":
case "observations-total-cost-by-model-timeseries":
const dateTruncObs = extractTimeSeries(input.groupBy);
if (!dateTruncObs) {
return [];
}
const rowsObs = await getObservationUsageByTime(
const rowsObs = await getTotalObservationUsageByTimeByModel(
input.projectId,
input.filter ?? [],
);
return rowsObs.map((row) => ({
startTime: row.start_time,
units: row.units,
cost: row.cost,
model: row.provided_model_name,
})) as DatabaseRow[];
return rowsObs as DatabaseRow[];
case "observations-usage-by-type-timeseries":
const rowsObsType = await getObservationUsageByTypeByTime(
input.projectId,
input.filter ?? [],
);
return rowsObsType as DatabaseRow[];
case "observations-cost-by-type-timeseries":
const rowsObsCostByType = await getObservationCostByTypeByTime(
input.projectId,
input.filter ?? [],
);
return rowsObsCostByType as DatabaseRow[];
case "distinct-models":
const models = await getDistinctModels(
@@ -271,6 +292,16 @@ export const dashboardRouter = createTRPCRouter({
);
return createHistogramData(data);
}),
executeQuery: protectedProjectProcedure
.input(
z.object({
projectId: z.string(),
query: customQuery,
}),
)
.query(async ({ input }) => {
return executeQuery(input.projectId, input.query as QueryType);
}),
});
const extractTimeSeries = (groupBy?: z.infer<typeof groupByInterface>) => {
@@ -281,3 +312,49 @@ const extractTimeSeries = (groupBy?: z.infer<typeof groupByInterface>) => {
});
return temporal?.type === "datetime" ? temporal.temporalUnit : undefined;
};
/**
* Execute a query using the QueryBuilder.
*
* @param projectId - The project ID
* @param query - The query configuration as defined in QueryType
* @returns The query result data
*/
export async function executeQuery(
projectId: string,
query: QueryType,
): Promise<Array<Record<string, unknown>>> {
try {
// Initialize query builder with ClickHouse client
const queryBuilder = new QueryBuilder();
// Build the query
const { query: compiledQuery, parameters } = queryBuilder.build(
query,
projectId,
);
// Execute the query
const result = await clickhouseClient({
tags: {
feature: "custom-queries",
type: query.view,
kind: "analytic",
projectId,
},
}).query({
query: compiledQuery,
query_params: parameters,
});
// Return the result
return (await result.json<Record<string, unknown>>()).data;
} catch (error) {
logger.error("Error executing query", { error, projectId, query });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to execute query",
cause: error,
});
}
}
@@ -16,7 +16,10 @@ import {
fetchDatasetItems,
getRunItemsByRunIdOrItemId,
} from "@/src/features/datasets/server/service";
import { getDatasetItemsTableCount, logger } from "@langfuse/shared/src/server";
import {
getDatasetRunItemsTableCount,
logger,
} from "@langfuse/shared/src/server";
import { createId as createCuid } from "@paralleldrive/cuid2";
const formatDatasetItemData = (data: string | null | undefined) => {
@@ -129,6 +132,7 @@ export const datasetRouter = createTRPCRouter({
datasets,
};
}),
// counts all dataset run items that match the filter
countAllDatasetItems: protectedProjectProcedure
.input(
z.object({
@@ -137,7 +141,7 @@ export const datasetRouter = createTRPCRouter({
}),
)
.query(async ({ input }) => {
const count = await getDatasetItemsTableCount({
const count = await getDatasetRunItemsTableCount({
projectId: input.projectId,
filter: input.filter ?? [],
});
@@ -66,7 +66,7 @@ export const entitlementAccess: Record<
},
},
"cloud:core": {
entitlements: [...cloudAllPlansEntitlements, "integration-blobstorage"],
entitlements: [...cloudAllPlansEntitlements],
entitlementLimits: {
"organization-member-count": false,
"data-access-days": 90,
@@ -76,7 +76,7 @@ export const entitlementAccess: Record<
},
},
"cloud:pro": {
entitlements: [...cloudAllPlansEntitlements, "integration-blobstorage"],
entitlements: [...cloudAllPlansEntitlements],
entitlementLimits: {
"annotation-queue-count": false,
"organization-member-count": false,
@@ -0,0 +1,98 @@
import { z } from "zod";
import { dashboardColumnDefinitions, singleFilter } from "@langfuse/shared";
import { type views } from "@/src/features/query/types";
const FilterArray = z.array(singleFilter);
const viewMappings: Record<z.infer<typeof views>, Record<string, string>[]> = {
traces: [
{
uiTableName: "Trace Name",
viewName: "name",
},
{
uiTableName: "User",
viewName: "userId",
},
{
uiTableName: "Release",
viewName: "release",
},
{
uiTableName: "Version",
viewName: "version",
},
],
observations: [
{
uiTableName: "Trace Name",
viewName: "traceName",
},
{
uiTableName: "Type",
viewName: "type",
},
{
uiTableName: "Model",
viewName: "providedModelName",
},
],
"scores-numeric": [
{
uiTableName: "Score Name",
viewName: "name",
},
{
uiTableName: "Score Source",
viewName: "source",
},
{
uiTableName: "Scores Data Type",
viewName: "dataType",
},
],
"scores-categorical": [
{
uiTableName: "Score Name",
viewName: "name",
},
{
uiTableName: "Score Source",
viewName: "source",
},
{
uiTableName: "Scores Data Type",
viewName: "dataType",
},
],
};
const isLegacyUiTableFilter = (
filter: z.infer<typeof singleFilter>,
): boolean => {
return dashboardColumnDefinitions.some(
(columnDef) => columnDef.uiTableName === filter.column,
);
};
export const mapLegacyUiTableFilterToView = (
view: z.infer<typeof views>,
filters: z.infer<typeof FilterArray>,
): z.infer<typeof FilterArray> => {
return filters.flatMap((filter) => {
// If it's not a legacy filter, return it as is
if (!isLegacyUiTableFilter(filter)) {
return [filter];
}
// Check if we have a match in our mapping
const definition = viewMappings[view].find(
(def) => def.uiTableName === filter.column,
);
// Ignore if there is no match
if (!definition) {
return [];
}
// Overwrite column name if a match is found
return [{ ...filter, column: definition.viewName }];
});
};
+374
View File
@@ -0,0 +1,374 @@
import { type z } from "zod";
import {
type views,
type ViewDeclarationType,
type DimensionsDeclarationType,
} from "@/src/features/query/types";
// The data model defines all available dimensions, measures, and the timeDimension for a given view.
// Make sure to update ./dashboardUiTableToViewMapping.ts if you make changes
export const traceView: ViewDeclarationType = {
name: "traces",
dimensions: {
id: {
sql: "id",
type: "string",
},
name: {
sql: "name",
type: "string",
},
userId: {
sql: "user_id",
type: "string",
},
sessionId: {
sql: "session_id",
type: "string",
},
release: {
sql: "release",
type: "string",
},
version: {
sql: "version",
type: "string",
},
environment: {
sql: "environment",
type: "string",
},
},
measures: {
count: {
sql: "count(*)",
alias: "count",
type: "count",
},
observationsCount: {
sql: "uniq(observations.id)",
alias: "observations_count",
type: "count",
relationTable: "observations",
},
scoresCount: {
sql: "uniq(scores.id)",
alias: "scores_count",
type: "count",
relationTable: "scores",
},
latency: {
sql: "date_diff('millisecond', min(observations.start_time), max(observations.end_time))",
alias: "latency",
type: "number",
relationTable: "observations",
},
},
tableRelations: {
observations: {
name: "observations",
joinConditionSql:
"ON traces.id = observations.trace_id AND traces.project_id = observations.project_id",
timeDimension: "start_time",
},
scores: {
name: "scores",
joinConditionSql:
"ON traces.id = scores.trace_id AND traces.project_id = scores.project_id",
timeDimension: "timestamp",
},
},
segments: [],
timeDimension: "timestamp",
baseCte: `traces FINAL`,
};
export const observationsView: ViewDeclarationType = {
name: "observations",
dimensions: {
id: {
sql: "id",
type: "string",
},
traceId: {
sql: "trace_id",
type: "string",
},
traceName: {
sql: "name",
alias: "trace_name",
type: "string",
relationTable: "traces",
},
environment: {
sql: "environment",
type: "string",
},
parentObservationId: {
sql: "parent_observation_id",
type: "string",
},
type: {
sql: "type",
type: "string",
},
name: {
sql: "name",
type: "string",
},
level: {
sql: "level",
type: "string",
},
version: {
sql: "version",
type: "string",
},
providedModelName: {
sql: "provided_model_name",
type: "string",
},
promptName: {
sql: "prompt_name",
type: "string",
},
promptVersion: {
sql: "prompt_version",
type: "string",
},
userId: {
sql: "user_id",
type: "string",
relationTable: "traces",
},
sessionId: {
sql: "session_id",
type: "string",
relationTable: "traces",
},
},
measures: {
count: {
sql: "count(*)",
alias: "count",
type: "count",
},
latency: {
sql: "date_diff('millisecond', any(observations.start_time), any(observations.end_time))",
alias: "latency",
type: "number",
},
totalTokens: {
sql: "sumMap(usage_details)['total']",
alias: "total_tokens",
type: "sum",
},
totalCost: {
sql: "sum(total_cost)",
alias: "total_cost",
type: "sum",
},
timeToFirstToken: {
sql: "date_diff('millisecond', any(observations.start_time), any(observations.completion_start_time))",
alias: "time_to_first_token",
type: "number",
},
countScores: {
sql: "uniq(scores.id)",
alias: "count_scores",
type: "count",
relationTable: "scores",
},
},
tableRelations: {
traces: {
name: "traces",
joinConditionSql:
"ON observations.trace_id = traces.id AND observations.project_id = traces.project_id",
timeDimension: "timestamp",
},
scores: {
name: "scores",
joinConditionSql:
"ON observations.id = scores.observation_id AND observations.project_id = scores.project_id",
timeDimension: "timestamp",
},
},
segments: [],
timeDimension: "start_time",
baseCte: `observations FINAL`,
};
const scoreBaseDimensions: DimensionsDeclarationType = {
id: {
sql: "id",
type: "string",
},
environment: {
sql: "environment",
type: "string",
},
name: {
sql: "name",
type: "string",
},
source: {
sql: "source",
type: "string",
},
dataType: {
sql: "data_type",
type: "string",
},
traceId: {
sql: "trace_id",
type: "string",
},
traceName: {
sql: "name",
alias: "trace_name",
type: "string",
relationTable: "traces",
},
userId: {
sql: "user_id",
alias: "user_id",
type: "string",
relationTable: "traces",
},
sessionId: {
sql: "session_id",
alias: "session_id",
type: "string",
relationTable: "traces",
},
observationId: {
sql: "observation_id",
type: "string",
},
observationName: {
sql: "name",
alias: "observation_name",
type: "string",
relationTable: "observations",
},
observationModelName: {
sql: "provided_model_name",
alias: "observation_model_name",
type: "string",
relationTable: "observations",
},
observationPromptName: {
sql: "prompt_name",
alias: "observation_prompt_name",
type: "string",
relationTable: "observations",
},
observationPromptVersion: {
sql: "prompt_version",
alias: "observation_prompt_version",
type: "string",
relationTable: "observations",
},
configId: {
sql: "config_id",
type: "string",
},
};
export const scoresNumericView: ViewDeclarationType = {
name: "scores_numeric",
dimensions: {
...scoreBaseDimensions,
},
measures: {
count: {
sql: "count(*)",
alias: "count",
type: "count",
},
value: {
sql: "any(value)",
alias: "value",
type: "number",
},
},
tableRelations: {
traces: {
name: "traces",
joinConditionSql:
"ON scores.trace_id = traces.id AND scores.project_id = traces.project_id",
timeDimension: "timestamp",
},
observations: {
name: "observations",
joinConditionSql:
"ON scores.observation_id = observations.id AND scores.project_id = observations.project_id",
timeDimension: "start_time",
},
},
segments: [
{
column: "data_type",
operator: "=",
value: "NUMERIC",
type: "string",
},
],
timeDimension: "timestamp",
baseCte: `scores scores_numeric FINAL`,
};
export const scoresCategoricalView: ViewDeclarationType = {
name: "scores_categorical",
dimensions: {
...scoreBaseDimensions,
stringValue: {
sql: "string_value",
type: "string",
},
},
measures: {
count: {
sql: "count(*)",
alias: "count",
type: "count",
},
},
tableRelations: {
traces: {
name: "traces",
joinConditionSql:
"ON scores.trace_id = traces.id AND scores.project_id = traces.project_id",
timeDimension: "timestamp",
},
observations: {
name: "observations",
joinConditionSql:
"ON scores.observation_id = observations.id AND scores.project_id = observations.project_id",
timeDimension: "start_time",
},
},
segments: [
{
column: "data_type",
// Here, we want to include everything that is not numeric.
operator: "does not contain",
value: "NUMERIC",
type: "string",
},
],
timeDimension: "timestamp",
baseCte: `scores scores_categorical FINAL`,
};
export const viewDeclarations: Record<
z.infer<typeof views>,
ViewDeclarationType
> = {
traces: traceView,
observations: observationsView,
"scores-numeric": scoresNumericView,
"scores-categorical": scoresCategoricalView,
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./dataModel";
export * from "./dashboardUiTableToViewMapping";
export * from "./types";
@@ -0,0 +1,805 @@
import { type z } from "zod";
import { convertDateToClickhouseDateTime } from "@langfuse/shared/src/server";
import {
type QueryType,
type ViewDeclarationType,
type views,
query as queryModel,
type metricAggregations,
type granularities,
} from "../types";
import { viewDeclarations } from "@/src/features/query/dataModel";
import {
FilterList,
createFilterFromFilterState,
} from "@langfuse/shared/src/server";
type AppliedDimensionType = {
table: string;
sql: string;
alias?: string;
relationTable?: string;
};
type AppliedMetricType = {
sql: string;
aggregation: z.infer<typeof metricAggregations>;
alias?: string;
relationTable?: string;
};
export class QueryBuilder {
private translateAggregation(
aggregation: z.infer<typeof metricAggregations>,
): string {
switch (aggregation) {
case "sum":
return "sum";
case "avg":
return "avg";
case "count":
return "count";
case "max":
return "max";
case "min":
return "min";
case "p50":
return "quantile(0.5)";
case "p75":
return "quantile(0.75)";
case "p90":
return "quantile(0.9)";
case "p95":
return "quantile(0.95)";
case "p99":
return "quantile(0.99)";
default:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const exhaustiveCheck: never = aggregation;
throw new Error(`Invalid aggregation: ${aggregation}`);
}
}
private getViewDeclaration(
viewName: z.infer<typeof views>,
): ViewDeclarationType {
if (!(viewName in viewDeclarations)) {
throw new Error(
`Invalid view. Must be one of ${Object.keys(viewDeclarations)}`,
);
}
return viewDeclarations[viewName];
}
private mapDimensions(
dimensions: Array<{ field: string }>,
view: ViewDeclarationType,
): AppliedDimensionType[] {
return dimensions.map((dimension) => {
if (!(dimension.field in view.dimensions)) {
throw new Error(
`Invalid dimension. Must be one of ${Object.keys(view.dimensions)}`,
);
}
const dim = view.dimensions[dimension.field];
return { ...dim, table: dim.relationTable || view.name };
});
}
private mapMetrics(
metrics: Array<{
measure: string;
aggregation: z.infer<typeof metricAggregations>;
}>,
view: ViewDeclarationType,
): AppliedMetricType[] {
return metrics.map((metric) => {
if (!(metric.measure in view.measures)) {
throw new Error(
`Invalid metric. Must be one of ${Object.keys(view.measures)}`,
);
}
return {
...view.measures[metric.measure],
aggregation: metric.aggregation,
};
});
}
private mapFilters(
filters: z.infer<typeof queryModel>["filters"],
view: ViewDeclarationType,
) {
// Transform our filters to match the column mapping format expected by createFilterFromFilterState
const columnMappings = filters.map((filter) => {
let clickhouseSelect: string;
let clickhouseTableName: string = view.name;
let type: string;
if (filter.column in view.dimensions) {
const dimension = view.dimensions[filter.column];
clickhouseSelect = dimension.sql;
type = dimension.type;
} else if (filter.column in view.measures) {
const measure = view.measures[filter.column];
clickhouseSelect = measure.sql;
type = measure.type;
} else if (filter.column === view.timeDimension) {
clickhouseSelect = view.timeDimension;
type = "datetime";
} else {
throw new Error(
`Invalid filter column. Must be one of ${Object.keys(view.dimensions)} or ${Object.keys(view.measures)} or ${view.timeDimension}`,
);
}
return {
uiTableName: filter.column,
uiTableId: filter.column,
clickhouseTableName,
clickhouseSelect,
queryPrefix: view.name,
type,
};
});
// Use the createFilterFromFilterState function to create proper Clickhouse filters
return createFilterFromFilterState(filters, columnMappings);
}
private addStandardFilters(
filterList: FilterList,
view: ViewDeclarationType,
projectId: string,
fromTimestamp: string,
toTimestamp: string,
) {
// Create column mappings for standard filters
const projectIdMapping = {
uiTableName: "project_id",
uiTableId: "project_id",
clickhouseTableName: view.name,
clickhouseSelect: "project_id",
queryPrefix: view.name,
type: "string",
};
const timeDimensionMapping = {
uiTableName: view.timeDimension,
uiTableId: view.timeDimension,
clickhouseTableName: view.name,
clickhouseSelect: view.timeDimension,
queryPrefix: view.name,
type: "datetime",
};
// Add project_id filter
const projectIdFilter = createFilterFromFilterState(
[
{
column: "project_id",
operator: "=",
value: projectId,
type: "string",
},
],
[projectIdMapping],
);
// Add fromTimestamp filter
const fromFilter = createFilterFromFilterState(
[
{
column: view.timeDimension,
operator: ">=",
value: new Date(fromTimestamp),
type: "datetime",
},
],
[timeDimensionMapping],
);
// Add toTimestamp filter
const toFilter = createFilterFromFilterState(
[
{
column: view.timeDimension,
operator: "<=",
value: new Date(toTimestamp),
type: "datetime",
},
],
[timeDimensionMapping],
);
// Add all filters to the filter list
filterList.push(...projectIdFilter, ...fromFilter, ...toFilter);
// Add segment filters if any
if (view.segments.length > 0) {
// Create column mappings for segment filters
const segmentsMappings = view.segments.map((segment) => ({
uiTableName: segment.column,
uiTableId: segment.column,
clickhouseTableName: view.name,
clickhouseSelect: segment.column,
queryPrefix: view.name,
type: segment.type,
}));
const segmentFilters = createFilterFromFilterState(
view.segments,
segmentsMappings,
);
filterList.push(...segmentFilters);
}
return filterList;
}
private collectRelationTables(
appliedDimensions: AppliedDimensionType[],
appliedMetrics: AppliedMetricType[],
) {
const relationTables = new Set<string>();
appliedDimensions.forEach((dimension) => {
if (dimension.relationTable) {
relationTables.add(dimension.relationTable);
}
});
appliedMetrics.forEach((metric) => {
if (metric.relationTable) {
relationTables.add(metric.relationTable);
}
});
return relationTables;
}
private buildJoins(
relationTables: Set<string>,
view: ViewDeclarationType,
filterList: FilterList,
query: QueryType,
) {
const relationJoins = [];
for (const relationTableName of relationTables) {
if (!(relationTableName in view.tableRelations)) {
throw new Error(
`Invalid relationTable: ${relationTableName}. Must be one of ${Object.keys(view.tableRelations)}`,
);
}
const relation = view.tableRelations[relationTableName];
let joinStatement = `LEFT JOIN ${relation.name} FINAL ${relation.joinConditionSql}`;
// Create time dimension mapping for the relation table
const relationTimeDimensionMapping = {
uiTableName: relation.timeDimension,
uiTableId: relation.timeDimension,
clickhouseTableName: relation.name,
clickhouseSelect: relation.timeDimension,
queryPrefix: relation.name,
type: "datetime",
};
// Add relation-specific timestamp filters
const fromFilter = createFilterFromFilterState(
[
{
column: relation.timeDimension,
operator: ">=",
value: new Date(query.fromTimestamp),
type: "datetime",
},
],
[relationTimeDimensionMapping],
);
const toFilter = createFilterFromFilterState(
[
{
column: relation.timeDimension,
operator: "<=",
value: new Date(query.toTimestamp),
type: "datetime",
},
],
[relationTimeDimensionMapping],
);
// Add filters to the filter list
filterList.push(...fromFilter, ...toFilter);
relationJoins.push(joinStatement);
}
return relationJoins;
}
private buildWhereClause(
filterList: FilterList,
parameters: Record<string, unknown>,
) {
if (filterList.length() === 0) return "";
// Use the FilterList's apply method to get the query and parameters
const { query, params } = filterList.apply();
// Add all parameters to the main parameters object
Object.assign(parameters, params);
// Return the WHERE clause with the query
return ` WHERE ${query}`;
}
private determineTimeGranularity(
fromTimestamp: string,
toTimestamp: string,
): z.infer<typeof granularities> {
const from = new Date(fromTimestamp);
const to = new Date(toTimestamp);
const diffMs = to.getTime() - from.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
// Choose appropriate granularity based on date range to get ~50 buckets
if (diffHours < 2) {
return "minute"; // Less than a 2h, use minutes
} else if (diffHours < 72) {
return "hour"; // Less than 3 days, use hours
} else if (diffHours < 1440) {
return "day"; // Less than 60 days, use days
} else if (diffHours < 8760) {
return "week"; // Less than a year, use weeks
} else {
return "month"; // Over a year, use months
}
}
private getTimeDimensionSql(
sql: string,
granularity: z.infer<typeof granularities>,
): string {
switch (granularity) {
case "minute":
return `toStartOfMinute(${sql})`;
case "hour":
return `toStartOfHour(${sql})`;
case "day":
return `toDate(${sql})`;
case "week":
return `toMonday(${sql})`;
case "month":
return `toStartOfMonth(${sql})`;
case "auto":
throw new Error(
`Granularity 'auto' is not supported for getTimeDimensionSql`,
);
default:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const exhaustiveCheck: never = granularity;
throw new Error(
`Invalid time granularity: ${granularity}. Must be one of minute, hour, day, week, month`,
);
}
}
private buildInnerDimensionsPart(
appliedDimensions: AppliedDimensionType[],
query: QueryType,
view: ViewDeclarationType,
) {
let dimensions = "";
// Add regular dimensions
if (appliedDimensions.length > 0) {
dimensions += `${appliedDimensions
.map(
(dimension) =>
`any(${dimension.table}.${dimension.sql}) as ${dimension.alias ?? dimension.sql}`,
)
.join(",\n")},`;
}
// Add time dimension if specified
if (query.timeDimension) {
const granularity =
query.timeDimension.granularity === "auto"
? this.determineTimeGranularity(
query.fromTimestamp,
query.toTimestamp,
)
: query.timeDimension.granularity;
const timeDimensionSql = this.getTimeDimensionSql(
`${view.name}.${view.timeDimension}`,
granularity,
);
dimensions += `any(${timeDimensionSql}) as time_dimension,`;
}
return dimensions;
}
private buildInnerMetricsPart(appliedMetrics: AppliedMetricType[]) {
return appliedMetrics.length > 0
? `${appliedMetrics.map((metric) => `${metric.sql} as ${metric.alias || metric.sql}`).join(",\n")}`
: "count(*) as count";
}
private buildInnerSelect(
view: ViewDeclarationType,
innerDimensionsPart: string,
innerMetricsPart: string,
fromClause: string,
) {
return `
SELECT
${view.name}.project_id,
${view.name}.id,
${innerDimensionsPart}
${innerMetricsPart}
${fromClause}
GROUP BY ${view.name}.project_id, ${view.name}.id`;
}
private buildOuterDimensionsPart(
appliedDimensions: AppliedDimensionType[],
hasTimeDimension: boolean,
) {
let dimensions = "";
// Add regular dimensions
if (appliedDimensions.length > 0) {
dimensions += `${appliedDimensions
.map(
(dimension) =>
`${dimension.alias ?? dimension.sql} as ${dimension.alias || dimension.sql}`,
)
.join(",\n")},`;
}
// Add time dimension if it exists
if (hasTimeDimension) {
dimensions += `time_dimension,`;
}
return dimensions;
}
private buildOuterMetricsPart(appliedMetrics: AppliedMetricType[]) {
return appliedMetrics.length > 0
? `${appliedMetrics.map((metric) => `${this.translateAggregation(metric.aggregation)}(${metric.alias || metric.sql}) as ${metric.aggregation}_${metric.alias || metric.sql}`).join(",\n")}`
: "count(*) as count";
}
private buildGroupByClause(
appliedDimensions: AppliedDimensionType[],
hasTimeDimension: boolean,
) {
const dimensions = [];
// Add regular dimensions
if (appliedDimensions.length > 0) {
dimensions.push(
...appliedDimensions.map(
(dimension) => dimension.alias ?? dimension.sql,
),
);
}
// Add time dimension if it exists
if (hasTimeDimension) {
dimensions.push("time_dimension");
}
return dimensions.length > 0 ? `GROUP BY ${dimensions.join(",\n")}` : "";
}
/**
* Builds a WITH FILL clause for time dimension to ensure continuous time series data.
* This fills in gaps in the time series with zero values based on the granularity.
* Only applied if timeDimension is used and no ORDER BY is specified.
*/
private buildWithFillClause(
timeDimension: {
granularity: z.infer<typeof granularities>;
} | null,
fromTimestamp: string,
toTimestamp: string,
orderBy: Array<{ field: string; direction: string }> | null,
parameters: Record<string, unknown>,
): string {
if (!timeDimension) {
return "";
}
if (orderBy && orderBy.length > 0) {
return ""; // Skip WITH FILL if ORDER BY is specified
}
// Determine granularity for WITH FILL if timeDimension is used
const granularity =
timeDimension.granularity === "auto"
? this.determineTimeGranularity(fromTimestamp, toTimestamp)
: timeDimension.granularity;
// Calculate appropriate STEP for WITH FILL based on granularity
let step: string;
switch (granularity) {
case "minute":
step = "INTERVAL 1 MINUTE";
break;
case "hour":
step = "INTERVAL 1 HOUR";
break;
case "day":
step = "INTERVAL 1 DAY";
break;
case "week":
step = "INTERVAL 1 WEEK";
break;
case "month":
step = "INTERVAL 1 MONTH";
break;
default:
step = "INTERVAL 1 DAY"; // Default to day if granularity is unknown
}
parameters["fillFromDate"] = convertDateToClickhouseDateTime(
new Date(fromTimestamp),
);
parameters["fillToDate"] = convertDateToClickhouseDateTime(
new Date(toTimestamp),
);
return ` WITH FILL FROM ${this.getTimeDimensionSql("{fillFromDate: DateTime64(3)}", granularity)} TO ${this.getTimeDimensionSql("{fillToDate: DateTime64(3)}", granularity)} STEP ${step}`;
}
private buildOuterSelect(
outerDimensionsPart: string,
outerMetricsPart: string,
innerQuery: string,
groupByClause: string,
orderByClause: string,
withFillClause: string,
) {
return `
SELECT
${outerDimensionsPart}
${outerMetricsPart}
FROM (${innerQuery})
${groupByClause}
${orderByClause}
${withFillClause}`;
}
/**
* Validates that the provided orderBy fields exist in the dimensions or metrics
* and returns the processed orderBy array with fully qualified field names.
*/
private validateAndProcessOrderBy(
orderBy: Array<{ field: string; direction: string }> | null,
appliedDimensions: AppliedDimensionType[],
appliedMetrics: AppliedMetricType[],
hasTimeDimension: boolean,
): Array<{ field: string; direction: string }> {
if (!orderBy || orderBy.length === 0) {
// Default order: time dimension if available, otherwise first metric, otherwise first dimension
if (hasTimeDimension) {
return [{ field: "time_dimension", direction: "asc" }];
} else if (appliedMetrics.length > 0) {
const firstMetric = appliedMetrics[0];
return [
{
field: `${firstMetric.aggregation}_${firstMetric.alias || firstMetric.sql}`,
direction: "desc",
},
];
} else if (appliedDimensions.length > 0) {
const firstDimension = appliedDimensions[0];
return [
{
field: firstDimension.alias || firstDimension.sql,
direction: "asc",
},
];
}
return [];
}
// Validate that each orderBy field exists in dimensions or metrics
return orderBy.map((item) => {
// Check if the field is a time dimension
if (hasTimeDimension && item.field === "time_dimension") {
return item;
}
// Check if the field is a dimension
const matchingDimension = appliedDimensions.find(
(dim) => dim.alias === item.field || dim.sql === item.field,
);
if (matchingDimension) {
return {
field: matchingDimension.alias || matchingDimension.sql,
direction: item.direction,
};
}
// Check if the field is a metric (with aggregation prefix)
const metricNamePattern =
/^(sum|avg|count|max|min|p50|p75|p90|p95|p99)_(.+)$/;
const metricMatch = item.field.match(metricNamePattern);
if (metricMatch) {
const [, aggregation, measureName] = metricMatch;
const matchingMetric = appliedMetrics.find(
(metric) =>
(metric.alias === measureName || metric.sql === measureName) &&
metric.aggregation === aggregation,
);
if (matchingMetric) {
return item;
}
}
throw new Error(
`Invalid orderBy field: ${item.field}. Must be one of the dimension or metric fields.`,
);
});
}
/**
* Builds the ORDER BY clause for the query.
*/
private buildOrderByClause(
processedOrderBy: Array<{ field: string; direction: string }>,
): string {
if (processedOrderBy.length === 0) {
return "";
}
return `ORDER BY ${processedOrderBy
.map((item) => `${item.field} ${item.direction}`)
.join(", ")}`;
}
/**
* We want to build a ClickHouse query based on the query provided and the viewDeclaration that was selected.
* The final query should always follow this pattern:
* ```
* SELECT
* <...dimensions>,
* <...metrics.map(metric => `${metric.aggregation}(${metric.alias})`>
* FROM (
* SELECT
* <baseCte>.project_id,
* <baseCte>.id
* <...dimensions.map(dimension => `any(${dimension.sql}) as ${dimension.alias}`>,
* <...metrics.map(metric => `${metric.sql} as ${metric.alias || metric.sql}`>
* FROM <baseCte>
* (...tableRelations.joinConditionSql)
* WHERE <...filters>
* GROUP BY <baseCte>.project_id, <baseCte>.id
* )
* GROUP BY <...dimensions>
* ORDER BY <fields with directions>
* ```
*/
public build(
query: QueryType,
projectId: string,
): { query: string; parameters: Record<string, unknown> } {
// Run zod validation
const parseResult = queryModel.safeParse(query);
if (!parseResult.success) {
throw new Error(
`Invalid query: ${JSON.stringify(parseResult.error.errors)}`,
);
}
// Initialize parameters object
const parameters: Record<string, unknown> = {};
// Get view declaration
const view = this.getViewDeclaration(query.view);
// Map dimensions and metrics
const appliedDimensions = this.mapDimensions(query.dimensions, view);
const appliedMetrics = this.mapMetrics(query.metrics, view);
// Create a new FilterList with the mapped filters
let filterList = new FilterList(this.mapFilters(query.filters, view));
// Add standard filters (project_id, timestamps)
filterList = this.addStandardFilters(
filterList,
view,
projectId,
query.fromTimestamp,
query.toTimestamp,
);
// Build the FROM clause with necessary JOINs
let fromClause = `FROM ${view.baseCte}`;
// Handle relation tables
const relationTables = this.collectRelationTables(
appliedDimensions,
appliedMetrics,
);
if (relationTables.size > 0) {
const relationJoins = this.buildJoins(
relationTables,
view,
filterList,
query,
);
fromClause += ` ${relationJoins.join(" ")}`;
}
// Build WHERE clause with parameters
fromClause += this.buildWhereClause(filterList, parameters);
// Build inner SELECT parts
const innerDimensionsPart = this.buildInnerDimensionsPart(
appliedDimensions,
query,
view,
);
const innerMetricsPart = this.buildInnerMetricsPart(appliedMetrics);
// Build inner SELECT
const innerQuery = this.buildInnerSelect(
view,
innerDimensionsPart,
innerMetricsPart,
fromClause,
);
// Build outer SELECT parts
const outerDimensionsPart = this.buildOuterDimensionsPart(
appliedDimensions,
!!query.timeDimension,
);
const outerMetricsPart = this.buildOuterMetricsPart(appliedMetrics);
const groupByClause = this.buildGroupByClause(
appliedDimensions,
!!query.timeDimension,
);
// Process and validate orderBy fields
const processedOrderBy = this.validateAndProcessOrderBy(
query.orderBy,
appliedDimensions,
appliedMetrics,
!!query.timeDimension,
);
// Build ORDER BY clause
const orderByClause = this.buildOrderByClause(processedOrderBy);
// Build WITH FILL clause for time dimension to fill gaps in timeseries
const withFillClause = this.buildWithFillClause(
query.timeDimension,
query.fromTimestamp,
query.toTimestamp,
query.orderBy,
parameters,
);
// Build final query
const sql = this.buildOuterSelect(
outerDimensionsPart,
outerMetricsPart,
innerQuery,
groupByClause,
orderByClause,
withFillClause,
);
return {
query: sql,
parameters,
};
}
}
+113
View File
@@ -0,0 +1,113 @@
import { z } from "zod";
import { singleFilter } from "@langfuse/shared";
export type ViewDeclarationType = z.infer<typeof viewDeclaration>;
export type DimensionsDeclarationType = z.infer<
typeof viewDeclaration
>["dimensions"];
export const viewDeclaration = z.object({
name: z.string(),
// This is the basic statement that we query from. Usually, this should be the view_name + FINAL or a more complex subquery.
baseCte: z.string(),
dimensions: z.record(
z.object({
sql: z.string(),
alias: z.string().optional(),
type: z.enum(["string", "number", "bool"]),
relationTable: z.string().optional(),
}),
),
measures: z.record(
z.object({
sql: z.string(),
alias: z.string().optional(),
type: z.enum(["count", "sum", "number"]),
relationTable: z.string().optional(),
}),
),
tableRelations: z.record(
z.object({
name: z.string(),
joinConditionSql: z.string(),
timeDimension: z.string(),
}),
),
// Segments are used to apply "constant" filters to the query. For example, if we only want one type of observations.
segments: z.array(singleFilter),
timeDimension: z.string(),
});
export const stringDateTime = z.string().datetime({ offset: true });
export const views = z.enum([
"traces",
"observations",
"scores-numeric",
"scores-categorical",
// "sessions",
// "users",
]);
export const dimension = z.object({
field: z.string(),
});
export const metricAggregations = z.enum([
"sum",
"avg",
"count",
"max",
"min",
"p50",
"p75",
"p90",
"p95",
"p99",
]);
export const metric = z.object({
measure: z.string(),
aggregation: metricAggregations,
});
export const granularities = z.enum([
"auto",
"minute",
"hour",
"day",
"week",
"month",
]);
export type QueryType = z.infer<typeof query>;
export const query = z
.object({
view: views,
dimensions: z.array(dimension),
metrics: z.array(metric),
filters: z.array(singleFilter),
timeDimension: z
.object({
// TODO: We may want to extend this and allow custom intervals like 3h in the future.
// auto tries to bin the data into approximately 50 buckets given the time range
granularity: granularities,
})
.nullable(),
fromTimestamp: stringDateTime,
toTimestamp: stringDateTime,
orderBy: z
.array(
z.object({
field: z.string(),
direction: z.enum(["asc", "desc"]),
}),
)
.nullable(),
})
.refine(
(query) =>
// Ensure fromTimestamp is before toTimestamp
new Date(query.fromTimestamp) < new Date(query.toTimestamp),
);
+6 -11
View File
@@ -5,7 +5,6 @@ import { isPrismaException } from "@/src/utils/exceptions";
import { logger, redis } from "@langfuse/shared/src/server";
import { type NextApiRequest, type NextApiResponse } from "next";
import { RateLimitService } from "@/src/features/public-api/server/RateLimitService";
export default async function handler(
req: NextApiRequest,
@@ -26,23 +25,19 @@ export default async function handler(
if (req.method === "GET") {
try {
// Do not apply rate limits as it can break applications on lower tier plans when using auth_check in prod
const projects = await prisma.project.findMany({
select: {
id: true,
name: true,
},
where: {
id: authCheck.scope.projectId,
// deletedAt: null, // here we want to include deleted projects and grey them in the UI.
},
});
const rateLimitCheck =
await RateLimitService.getInstance().rateLimitRequest(
authCheck.scope,
"public-api",
);
if (rateLimitCheck?.isRateLimited()) {
return rateLimitCheck.sendRestResponseIfLimited(res);
}
return res.status(200).json({
data: projects.map((project) => ({
id: project.id,
+21 -30
View File
@@ -138,35 +138,24 @@ export default function Dashboard() {
[dateRange],
);
const timeFilter = dateRange
? [
{
type: "datetime" as const,
column: "startTime",
operator: ">" as const,
value: dateRange.from,
},
{
type: "datetime" as const,
column: "startTime",
operator: "<" as const,
value: dateRange.to,
},
]
: [
{
type: "datetime" as const,
column: "startTime",
operator: ">" as const,
value: new Date(new Date().getTime() - 1000),
},
{
type: "datetime" as const,
column: "startTime",
operator: "<" as const,
value: new Date(),
},
];
const fromTimestamp = dateRange
? dateRange.from
: new Date(new Date().getTime() - 1000);
const toTimestamp = dateRange ? dateRange.to : new Date();
const timeFilter = [
{
type: "datetime" as const,
column: "startTime",
operator: ">" as const,
value: fromTimestamp,
},
{
type: "datetime" as const,
column: "startTime",
operator: "<" as const,
value: toTimestamp,
},
];
const environmentFilter = convertSelectedEnvironmentsToFilter(
["environment"],
@@ -248,7 +237,9 @@ export default function Dashboard() {
<TracesBarListChart
className="col-span-1 xl:col-span-2"
projectId={projectId}
globalFilterState={mergedFilterState}
globalFilterState={[...userFilterState, ...environmentFilter]}
fromTimestamp={fromTimestamp}
toTimestamp={toTimestamp}
isLoading={environmentFilterOptions.isLoading}
/>
{!disableExpensiveDashboardComponents && (
@@ -0,0 +1,137 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useState } from "react";
import { api } from "@/src/utils/api";
import Page from "@/src/components/layouts/page";
import { Button } from "@/src/components/ui/button";
import { CodeMirrorEditor } from "@/src/components/editor/CodeMirrorEditor";
import { Card } from "@/src/components/ui/card";
import { MarkdownJsonView } from "@/src/components/ui/MarkdownJsonView";
import { env } from "@/src/env.mjs";
export default function QueryPlayground() {
const session = useSession();
const isCloudAdmin =
env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION !== undefined &&
session.data?.user?.admin === true;
const router = useRouter();
const projectId = router.query.projectId as string;
const [queryCode, setQueryCode] = useState<string>(`{
"view": "traces",
"dimensions": [{ "field": "name" }],
"metrics": [{ "measure": "count", "aggregation": "count" }],
"filters": [],
"timeDimension": {
"granularity": "day"
},
"fromTimestamp": "${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()}",
"toTimestamp": "${new Date().toISOString()}",
"page": 0,
"limit": 50
}`);
const [error, setError] = useState<string | null>(null);
// Query execution state
const [queryInput, setQueryInput] = useState<any>(null);
// Execute query mutation
const { data, isLoading } = api.dashboard.executeQuery.useQuery(
{
projectId,
query: queryInput?.query || {
view: "traces",
dimensions: [],
metrics: [],
filters: [],
timeDimension: null,
fromTimestamp: new Date(
Date.now() - 7 * 24 * 60 * 60 * 1000,
).toISOString(),
toTimestamp: new Date().toISOString(),
page: 0,
limit: 10,
},
},
{
enabled: !!queryInput, // Only run the query when queryInput is set (via button click)
retry: false,
},
);
const executeQuery = () => {
setError(null);
try {
// Parse the JSON query
const parsedQuery = JSON.parse(queryCode);
setQueryInput({
projectId,
query: parsedQuery,
});
} catch (err) {
setError(`Invalid JSON: ${(err as Error).message}`);
}
};
return (
isCloudAdmin && (
<Page
headerProps={{
title: "Query Playground",
help: {
description:
"Test and visualize queries using Langfuse's query builder",
href: "https://langfuse.com/docs", // Update with actual docs link when available
},
}}
>
<div className="flex h-full flex-col gap-4 overflow-hidden">
<div className="grid min-h-0 flex-1 grid-cols-2 gap-4 overflow-hidden">
{/* Query Editor */}
<Card className="flex flex-col overflow-hidden p-4">
<h2 className="mb-2 text-lg font-medium">Query</h2>
<div className="flex-1 overflow-hidden">
<CodeMirrorEditor
value={queryCode}
onChange={setQueryCode}
mode="json"
className="h-full"
minHeight={100}
/>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-red-500">{error}</div>
<Button
onClick={executeQuery}
disabled={isLoading && !!queryInput}
>
{isLoading && !!queryInput ? "Running..." : "Run Query"}
</Button>
</div>
</Card>
{/* Results */}
<Card className="flex flex-col overflow-hidden p-4">
<h2 className="mb-2 text-lg font-medium">Results</h2>
<div className="flex-1 overflow-auto">
{data ? (
<div className="h-full overflow-auto">
<MarkdownJsonView content={data} />
</div>
) : (
<div className="flex h-full items-center justify-center p-4 text-muted-foreground">
{isLoading && !!queryInput
? "Loading results..."
: error
? "Query error"
: "Write a query and click 'Run Query' to see results"}
</div>
)}
</div>
</Card>
</div>
</div>
</Page>
)
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "worker",
"version": "3.45.0",
"version": "3.45.2",
"description": "",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "v3.45.0";
export const VERSION = "v3.45.2";
+1 -1
View File
@@ -156,7 +156,7 @@ export const createEvalJobs = async ({
>(Prisma.sql`
SELECT dataset_item_id as id
FROM dataset_run_items as dri
JOIN dataset_items as di ON di.id = dri.dataset_item_id
JOIN dataset_items as di ON di.id = dri.dataset_item_id AND di.project_id = ${event.projectId}
WHERE dri.project_id = ${event.projectId}
AND dri.trace_id = ${event.traceId}
${condition}