Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
import { TimeSeriesQueryPlugin, replaceVariables } from '@perses-dev/plugin-system';
import { milliseconds } from 'date-fns';
import { DurationString, parseDurationString, TimeSeries } from '@perses-dev/spec';
import { LokiClient } from '../../model/loki-client';
import { LokiMatrixResult } from '../../model/loki-client-types';
import { LokiClient, toUnixSeconds } from '../../model/loki-client';
import { LokiMatrixResult, LokiVectorResult } from '../../model/loki-client-types';
import { DEFAULT_DATASOURCE } from '../constants';
import { LokiTimeSeriesQuerySpec } from './loki-time-series-query-types';

Expand Down Expand Up @@ -88,6 +88,19 @@ function convertMatrixToTimeSeries(matrix: LokiMatrixResult[]): TimeSeries[] {
});
}

function convertVectorToTimeSeries(vector: LokiVectorResult[]): TimeSeries[] {
return vector.map((series) => {
const name = Object.entries(series.metric)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
return {
name,
values: [[Number(series.value[0]) * 1000, Number(series.value[1])]],
labels: series.metric,
};
});
}

export const getLokiTimeSeriesData: TimeSeriesQueryPlugin<LokiTimeSeriesQuerySpec>['getTimeSeriesData'] = async (
spec,
context
Expand All @@ -107,7 +120,27 @@ export const getLokiTimeSeriesData: TimeSeriesQueryPlugin<LokiTimeSeriesQuerySpe

const { start, end } = context.timeRange;

// Calculate proper step using similar logic to Prometheus
if (context.mode === 'instant') {
Comment thread
tgitelman marked this conversation as resolved.
const response = await client.query({ query, time: toUnixSeconds(end) });

if (response.data.resultType === 'vector') {
return {
series: convertVectorToTimeSeries(response.data.result as LokiVectorResult[]),
timeRange: { start, end },
stepMs: DEFAULT_MIN_STEP_SECONDS * 1000,
metadata: { executedQueryString: query },
};
}

return {
Comment thread
tgitelman marked this conversation as resolved.
series: [],
timeRange: { start, end },
stepMs: DEFAULT_MIN_STEP_SECONDS * 1000,
metadata: { notices: [{ type: 'warning', message: "log streams are not supported in 'instant' mode" }] },
};
}

// Range mode (default)
const minStepSeconds = spec.step
? (getDurationStringSeconds(spec.step as DurationString) ?? DEFAULT_MIN_STEP_SECONDS)
: DEFAULT_MIN_STEP_SECONDS;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jest.mock('echarts/core');

import { TimeSeriesQueryContext } from '@perses-dev/plugin-system';
import { DatasourceSpec } from '@perses-dev/spec';
import { LokiQueryRangeMatrixResponse, LokiQueryRangeResponse } from '../../model/loki-client-types';
import { LokiQueryRangeMatrixResponse, LokiQueryRangeResponse, LokiQueryResponse } from '../../model/loki-client-types';
import { LokiDatasource } from '../../datasources/loki-datasource';
import { LokiDatasourceSpec } from '../../datasources/loki-datasource/loki-datasource-types';
import { LokiTimeSeriesQuery } from './LokiTimeSeriesQuery';
Expand Down Expand Up @@ -48,6 +48,23 @@ lokiStubClient.queryRange = jest.fn(async () => {
return stubResponse as LokiQueryRangeResponse;
});

// Mock instant query
lokiStubClient.query = jest.fn(async () => {
const stubResponse: LokiQueryResponse = {
status: 'success',
data: {
resultType: 'vector',
result: [
{
metric: { __name__: 'loki_count', service: 'api' },
value: [1686141338, '99'],
},
],
},
};
return stubResponse;
});

const getDatasourceClient: jest.Mock = jest.fn(() => {
return lokiStubClient;
});
Expand All @@ -62,7 +79,7 @@ const getDatasource: jest.Mock = jest.fn((): DatasourceSpec<LokiDatasourceSpec>
};
});

const createStubContext = (): TimeSeriesQueryContext => {
const createStubContext = (overrides?: Partial<TimeSeriesQueryContext>): TimeSeriesQueryContext => {
const stubTimeSeriesContext: TimeSeriesQueryContext = {
datasourceStore: {
getDatasource: getDatasource,
Expand All @@ -74,10 +91,11 @@ const createStubContext = (): TimeSeriesQueryContext => {
setSavedDatasources: jest.fn(),
},
timeRange: {
end: new Date('01-01-2025'),
start: new Date('01-02-2025'),
end: new Date('2025-01-01T00:00:00Z'),
start: new Date('2024-12-25T00:00:00Z'),
},
variableState: {},
...overrides,
};
return stubTimeSeriesContext;
};
Expand All @@ -98,4 +116,51 @@ describe('LokiTimeSeriesQuery', () => {
const initialOptions = LokiTimeSeriesQuery.createInitialOptions();
expect(initialOptions).toEqual({ query: '' });
});

describe('instant mode', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should call client.query() with end time as unix seconds and return vector series', async () => {
const context = createStubContext({ mode: 'instant' });
const result = await LokiTimeSeriesQuery.getTimeSeriesData(
{ query: 'count_over_time({service="api"} [1h])' },
context
);

expect(lokiStubClient.query).toHaveBeenCalledTimes(1);
expect(lokiStubClient.queryRange).not.toHaveBeenCalled();
const callArgs = (lokiStubClient.query as jest.Mock).mock.calls[0][0];
expect(callArgs.time).toBe(Math.floor(context.timeRange.end.getTime() / 1000).toString());
expect(result.series).toHaveLength(1);
expect(result.series[0]?.values[0]?.[1]).toBe(99);
});

it('should return empty series with warning notice for streams resultType', async () => {
(lokiStubClient.query as jest.Mock).mockResolvedValueOnce({
status: 'success',
data: {
resultType: 'streams',
result: [{ stream: { service: 'api' }, values: [['1686141338000000000', 'log line']] }],
},
} as LokiQueryResponse);

const context = createStubContext({ mode: 'instant' });
const result = await LokiTimeSeriesQuery.getTimeSeriesData({ query: '{service="api"}' }, context);

expect(result.series).toHaveLength(0);
expect(result.metadata?.notices?.[0]?.type).toBe('warning');
expect(result.metadata?.notices?.[0]?.message).toBe("log streams are not supported in 'instant' mode");
});
});

it('should use queryRange when mode is not set', async () => {
jest.clearAllMocks();
const context = createStubContext();
await LokiTimeSeriesQuery.getTimeSeriesData({ query: 'count_over_time({service="api"} [1h])' }, context);

expect(lokiStubClient.queryRange).toHaveBeenCalledTimes(1);
expect(lokiStubClient.query).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
// limitations under the License.

import { DatasourceSelector } from '@perses-dev/spec';
import { LokiQueryRangeMatrixResponse } from '../../model/loki-client-types';
import { LokiQueryRangeMatrixResponse, LokiQueryResponse } from '../../model/loki-client-types';

export interface LokiTimeSeriesQuerySpec {
query: string;
datasource?: DatasourceSelector;
step?: string;
}

export type LokiTimeSeriesQueryResponse = LokiQueryRangeMatrixResponse;
export type LokiTimeSeriesQueryResponse = LokiQueryRangeMatrixResponse | LokiQueryResponse;
Loading