Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions clickhouse/src/model/click-house-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { formatClickHouseDateTime, query, replaceTimeRangePlaceholders } from './click-house-client';

describe('ClickHouse client', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('should replace time range placeholders', () => {
expect(
replaceTimeRangePlaceholders(
"SELECT * FROM logs WHERE timestamp BETWEEN '{start}' AND '{end}'",
'2025-01-01 00:00:00',
'2025-01-02 00:00:00'
)
).toEqual("SELECT * FROM logs WHERE timestamp BETWEEN '2025-01-01 00:00:00' AND '2025-01-02 00:00:00'");
});

it('should format time range dates for ClickHouse SQL literals', () => {
expect(formatClickHouseDateTime(new Date('2025-01-01T00:00:00.000Z'))).toBe('2025-01-01 00:00:00');
});

it('should send interpolated query to ClickHouse', async () => {
const fetchMock = jest.fn<Promise<Pick<Response, 'ok' | 'json'>>, [string, RequestInit?]>(async () => ({
ok: true,
json: jest.fn(async () => ({ data: [] })),
}));
global.fetch = fetchMock as unknown as typeof fetch;

await query(
{
query: "SELECT * FROM logs WHERE timestamp BETWEEN '{start}' AND '{end}'",
start: '2025-01-01 00:00:00',
end: '2025-01-02 00:00:00',
},
{
datasourceUrl: 'http://clickhouse.example.com',
}
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const [calledUrl] = fetchMock.mock.calls[0]!;
const url = new URL(calledUrl);
expect(url.searchParams.get('query')).toBe(
"SELECT * FROM logs WHERE timestamp BETWEEN '2025-01-01 00:00:00' AND '2025-01-02 00:00:00' FORMAT JSON"
);
});
});
15 changes: 14 additions & 1 deletion clickhouse/src/model/click-house-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { RequestHeaders } from '@perses-dev/core';

export interface ClickHouseQueryParams {
query: string;
start?: string;
end?: string;
database?: string;
}

Expand All @@ -32,6 +34,17 @@ export interface ClickHouseClient {
query: (params: { start: string; end: string; query: string }) => Promise<ClickHouseQueryResponse>;
}

export function replaceTimeRangePlaceholders(query: string, start?: string, end?: string): string {
return query.replaceAll('{start}', start ?? '{start}').replaceAll('{end}', end ?? '{end}');
}

export function formatClickHouseDateTime(date: Date): string {
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, '');
}

export async function query(
params: ClickHouseQueryParams,
queryOptions: ClickHouseQueryOptions
Expand All @@ -43,7 +56,7 @@ export async function query(
throw new Error('No query provided in params');
}

let finalQuery = params.query.trim();
let finalQuery = replaceTimeRangePlaceholders(params.query.trim(), params.start, params.end);
if (!finalQuery.toUpperCase().includes('FORMAT')) {
finalQuery += ' FORMAT JSON';
}
Expand Down
2 changes: 1 addition & 1 deletion clickhouse/src/model/click-house-data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ export interface ClickHouseTimeSeriesData extends TimeSeriesData {

export interface TimeSeriesEntry {
time: string;
log_count: number | string;
[key: string]: number | string | null | undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ const createStubContext = (): ClickHouseQueryContext => {
setSavedDatasources: jest.fn(),
},
timeRange: {
end: new Date('01-01-2025'),
start: new Date('01-02-2025'),
end: new Date('2025-01-02T00:00:00.000Z'),
start: new Date('2025-01-01T00:00:00.000Z'),
},
variableState: {},
};
Expand All @@ -71,4 +71,23 @@ describe('ClickHouseLogQuery', () => {
const initialOptions = ClickHouseLogQuery.createInitialOptions();
expect(initialOptions).toEqual({ query: '' });
});

it('should run query with interpolated time range', async () => {
const response = await ClickHouseLogQuery.getLogData(
{
query: "SELECT * FROM application_logs WHERE timestamp >= '{start}' AND timestamp <= '{end}'",
},
createStubContext()
);

expect(clickhouseStubClient.query).toHaveBeenCalledWith({
start: '2025-01-01 00:00:00',
end: '2025-01-02 00:00:00',
query:
"SELECT * FROM application_logs WHERE timestamp >= '2025-01-01 00:00:00' AND timestamp <= '2025-01-02 00:00:00'",
});
expect(response.metadata?.executedQueryString).toBe(
"SELECT * FROM application_logs WHERE timestamp >= '2025-01-01 00:00:00' AND timestamp <= '2025-01-02 00:00:00'"
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@

import { replaceVariables } from '@perses-dev/plugin-system';
import { LogEntry, LogData } from '@perses-dev/core';
import { ClickHouseClient, ClickHouseQueryResponse } from '../../model/click-house-client';
import {
ClickHouseClient,
ClickHouseQueryResponse,
formatClickHouseDateTime,
replaceTimeRangePlaceholders,
} from '../../model/click-house-client';
import { DEFAULT_DATASOURCE } from '../constants';
import { ClickHouseLogQuerySpec } from './click-house-log-query-types';
import { LogQueryPlugin } from './log-query-plugin-interface';
Expand Down Expand Up @@ -83,18 +88,21 @@ export const getClickHouseLogData: LogQueryPlugin<ClickHouseLogQuerySpec>['getLo
)) as ClickHouseClient;

const { start, end } = context.timeRange;
const startTime = formatClickHouseDateTime(start);
const endTime = formatClickHouseDateTime(end);
const executedQueryString = replaceTimeRangePlaceholders(query, startTime, endTime);

const response: ClickHouseQueryResponse = await client.query({
start: start.getTime().toString(),
end: end.getTime().toString(),
query,
start: startTime,
end: endTime,
query: executedQueryString,
});

return {
timeRange: { start, end },
logs: convertStreamsToLogs(response.data as LogEntry[]),
metadata: {
executedQueryString: query,
executedQueryString,
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ clickhouseStubClient.query = jest.fn(async () => {
const stubResponse: ClickHouseQueryResponse = {
status: 'success',
data: [
{ time: '2025-09-09 05:18:00', log_count: '277' },
{ time: '2025-09-09 05:19:00', log_count: '156102' },
{ time: '2025-09-09 05:18:00', avg_cpu: '2.5', max_memory: 277, service: 'api' },
{ time: '2025-09-09 05:19:00', avg_cpu: '3.5', max_memory: 156102, service: 'api' },
],
};
return stubResponse as ClickHouseQueryResponse;
Expand Down Expand Up @@ -65,8 +65,8 @@ const createStubContext = (): TimeSeriesQueryContext => {
setSavedDatasources: jest.fn(),
},
timeRange: {
end: new Date('01-01-2025'),
start: new Date('01-02-2025'),
end: new Date('2025-01-02T00:00:00.000Z'),
start: new Date('2025-01-01T00:00:00.000Z'),
},
variableState: {},
};
Expand All @@ -90,11 +90,92 @@ describe('ClickHouseTimeSeriesQuery', () => {
expect(initialOptions).toEqual({ query: '' });
});

it('should run query and return ClickHouse data only', async () => {
const client = getDatasourceClient();
const resp = await client.query('SELECT count(*) FROM otel_logs');
expect(resp.data.length).toBeGreaterThan(0);
expect(resp.data[0]).toHaveProperty('time');
expect(resp.data[0]).toHaveProperty('log_count');
it('should run query with interpolated time range and return one series per numeric column', async () => {
const response = await ClickHouseTimeSeriesQuery.getTimeSeriesData(
{
query:
"SELECT toStartOfMinute(timestamp) as time, avg(cpu_usage) as avg_cpu, max(memory_usage) as max_memory FROM system_metrics WHERE timestamp BETWEEN '{start}' AND '{end}' GROUP BY time ORDER BY time",
},
createStubContext()
);

expect(clickhouseStubClient.query).toHaveBeenCalledWith({
start: '2025-01-01 00:00:00',
end: '2025-01-02 00:00:00',
query:
"SELECT toStartOfMinute(timestamp) as time, avg(cpu_usage) as avg_cpu, max(memory_usage) as max_memory FROM system_metrics WHERE timestamp BETWEEN '2025-01-01 00:00:00' AND '2025-01-02 00:00:00' GROUP BY time ORDER BY time",
});
expect(response.series).toEqual([
{
name: 'avg_cpu',
values: [
[new Date('2025-09-09 05:18:00').getTime(), 2.5],
[new Date('2025-09-09 05:19:00').getTime(), 3.5],
],
},
{
name: 'max_memory',
values: [
[new Date('2025-09-09 05:18:00').getTime(), 277],
[new Date('2025-09-09 05:19:00').getTime(), 156102],
],
},
]);
expect(response.stepMs).toBe(60 * 1000);
expect(response.metadata?.executedQueryString).toBe(
"SELECT toStartOfMinute(timestamp) as time, avg(cpu_usage) as avg_cpu, max(memory_usage) as max_memory FROM system_metrics WHERE timestamp BETWEEN '2025-01-01 00:00:00' AND '2025-01-02 00:00:00' GROUP BY time ORDER BY time"
);
});

it('should infer daily query step from returned timestamps', async () => {
(clickhouseStubClient.query as jest.Mock).mockResolvedValueOnce({
status: 'success',
data: [
{ time: '2026-01-01 22:00:00', flights: 80 },
{ time: '2026-01-02 22:00:00', flights: 56 },
{ time: '2026-01-03 22:00:00', flights: 32 },
],
});

const response = await ClickHouseTimeSeriesQuery.getTimeSeriesData(
{
query:
"SELECT toStartOfDay(timestamp) as time, sum(flights_count) as flights FROM flight WHERE timestamp BETWEEN '{start}' AND '{end}' GROUP BY time ORDER BY time",
},
createStubContext()
);

expect(response.stepMs).toBe(24 * 60 * 60 * 1000);
expect(response.series).toEqual([
{
name: 'flights',
values: [
[new Date('2026-01-01 22:00:00').getTime(), 80],
[new Date('2026-01-02 22:00:00').getTime(), 56],
[new Date('2026-01-03 22:00:00').getTime(), 32],
],
},
]);
});

it('should keep timezone daily buckets daily across daylight saving time changes', async () => {
(clickhouseStubClient.query as jest.Mock).mockResolvedValueOnce({
status: 'success',
data: [
{ time: '2026-03-28 22:00:00', flights: 80 },
{ time: '2026-03-29 21:00:00', flights: 56 },
{ time: '2026-03-30 21:00:00', flights: 32 },
],
});

const response = await ClickHouseTimeSeriesQuery.getTimeSeriesData(
{
query:
"SELECT toStartOfDay(timestamp) as time, sum(flights_count) as flights FROM flight WHERE timestamp BETWEEN '{start}' AND '{end}' GROUP BY time ORDER BY time",
},
createStubContext()
);

expect(response.stepMs).toBe(24 * 60 * 60 * 1000);
});
});
Loading
Loading