Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae508d820 | ||
|
|
a6e581d349 | ||
|
|
9e77738a2f | ||
|
|
d15c0a27f5 | ||
|
|
a8626841b5 | ||
|
|
5945a93b7d | ||
|
|
9c447de584 | ||
|
|
48df0e04de | ||
|
|
0897ac753b | ||
|
|
d00c63100e |
@@ -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
@@ -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
@@ -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 @@
|
||||
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 }];
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.45.0",
|
||||
"version": "3.45.2",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.45.0";
|
||||
export const VERSION = "v3.45.2";
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user