Skip to content
Snippets Groups Projects
Verified Commit efa275f9 authored by Andrew Fontaine's avatar Andrew Fontaine Committed by GitLab
Browse files

Merge branch 'drosse/add-tracing-analytics' into 'master'

Add support for Tracing Analytics API

See merge request !142204



Merged-by: Andrew Fontaine's avatarAndrew Fontaine <afontaine@gitlab.com>
Approved-by: default avatarJay Montal <jmontal@gitlab.com>
Approved-by: default avatarSam Word <sword@gitlab.com>
Approved-by: default avatarAakriti Gupta <agupta@gitlab.com>
Reviewed-by: Andrew Fontaine's avatarAndrew Fontaine <afontaine@gitlab.com>
Co-authored-by: default avatarDaniele Rossetti <drossetti@gitlab.com>
parents 9ecf7af3 e4ba8ea1
No related branches found
No related tags found
2 merge requests!144312Change service start (cut-off) date for code suggestions to March 15th,!142204Add support for Tracing Analytics API
Pipeline #1148780115 passed
Showing with 379 additions and 53 deletions
......@@ -235,6 +235,20 @@ async function fetchTraces(tracingUrl, { filters = {}, pageToken, pageSize, sort
}
}
async function fetchTracesAnalytics(tracingAnalyticsUrl, { filters = {} } = {}) {
const params = filterObjToQueryParams(filters);
try {
const { data } = await axios.get(tracingAnalyticsUrl, {
withCredentials: true,
params,
});
return data.results ?? [];
} catch (e) {
return reportErrorAndThrow(e);
}
}
async function fetchServices(servicesUrl) {
try {
const { data } = await axios.get(servicesUrl, {
......@@ -339,6 +353,7 @@ export function buildClient(config) {
const {
provisioningUrl,
tracingUrl,
tracingAnalyticsUrl,
servicesUrl,
operationsUrl,
metricsUrl,
......@@ -353,6 +368,10 @@ export function buildClient(config) {
throw new Error('tracingUrl param must be a string');
}
if (typeof tracingAnalyticsUrl !== 'string') {
throw new Error('tracingAnalyticsUrl param must be a string');
}
if (typeof servicesUrl !== 'string') {
throw new Error('servicesUrl param must be a string');
}
......@@ -373,6 +392,7 @@ export function buildClient(config) {
enableObservability: () => enableObservability(provisioningUrl),
isObservabilityEnabled: () => isObservabilityEnabled(provisioningUrl),
fetchTraces: (options) => fetchTraces(tracingUrl, options),
fetchTracesAnalytics: (options) => fetchTracesAnalytics(tracingAnalyticsUrl, options),
fetchTrace: (traceId) => fetchTrace(tracingUrl, traceId),
fetchServices: () => fetchServices(servicesUrl),
fetchOperations: (serviceName) => fetchOperations(operationsUrl, serviceName),
......
......@@ -47,6 +47,7 @@ export default {
nextPageToken: null,
highlightedTraceId: null,
sortBy: sortBy || DEFAULT_SORTING_OPTION,
analytics: [],
};
},
computed: {
......@@ -76,22 +77,30 @@ export default {
},
created() {
this.debouncedChartItemOver = debounce(this.chartItemOver, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
this.fetchTraces();
this.fetchTraces(true);
},
methods: {
async fetchTraces() {
async fetchTraces(updateAnalytics = false) {
this.loading = true;
try {
const {
traces,
next_page_token: nextPageToken,
} = await this.observabilityClient.fetchTraces({
filters: this.filters,
pageToken: this.nextPageToken,
pageSize: PAGE_SIZE,
sortBy: this.sortBy,
});
const apiRequests = [
this.observabilityClient.fetchTraces({
filters: this.filters,
pageToken: this.nextPageToken,
pageSize: PAGE_SIZE,
sortBy: this.sortBy,
}),
];
if (updateAnalytics) {
apiRequests.push(
this.observabilityClient.fetchTracesAnalytics({ filters: this.filters }),
);
}
const [tracesResults, analyticsResults] = await Promise.all(apiRequests);
this.analytics = analyticsResults;
const { traces, next_page_token: nextPageToken } = tracesResults;
this.traces = [...this.traces, ...traces];
if (nextPageToken) {
this.nextPageToken = nextPageToken;
......@@ -112,7 +121,7 @@ export default {
this.filters = filterTokensToFilterObj(filterTokens);
this.nextPageToken = null;
this.traces = [];
this.fetchTraces();
this.fetchTraces(true);
},
onSort(sortBy) {
this.sortBy = sortBy;
......@@ -121,7 +130,7 @@ export default {
this.fetchTraces();
},
bottomReached() {
this.fetchTraces({ skipUpdatingChartRange: true });
this.fetchTraces();
},
chartItemSelected({ traceId }) {
this.onTraceClicked({ traceId });
......
......@@ -41,6 +41,7 @@ def shared_model(project)
oauthUrl: ::Gitlab::Observability.oauth_url,
provisioningUrl: ::Gitlab::Observability.provisioning_url(project),
tracingUrl: ::Gitlab::Observability.tracing_url(project),
tracingAnalyticsUrl: ::Gitlab::Observability.tracing_analytics_url(project),
servicesUrl: ::Gitlab::Observability.services_url(project),
operationsUrl: ::Gitlab::Observability.operations_url(project),
metricsUrl: ::Gitlab::Observability.metrics_url(project),
......
......@@ -11,6 +11,10 @@ def tracing_url(project)
"#{::Gitlab::Observability.observability_url}/v3/query/#{project.id}/traces"
end
def tracing_analytics_url(project)
"#{::Gitlab::Observability.observability_url}/v3/query/#{project.id}/traces/analytics"
end
def services_url(project)
"#{::Gitlab::Observability.observability_url}/v3/query/#{project.id}/services"
end
......
......@@ -55,13 +55,14 @@ describe('TracingList', () => {
observabilityClientMock.fetchTraces.mockResolvedValue(mockResponse);
});
describe('fetching traces', () => {
describe('fetching data', () => {
beforeEach(async () => {
await mountComponent();
});
it('fetches the traces and renders the trace list with filtered search', () => {
it('fetches data and renders the trace list with filtered search', () => {
expect(observabilityClientMock.fetchTraces).toHaveBeenCalled();
expect(observabilityClientMock.fetchTracesAnalytics).toHaveBeenCalled();
expect(findLoadingIcon().exists()).toBe(false);
expect(findTableList().exists()).toBe(true);
expect(findFilteredSearch().exists()).toBe(true);
......@@ -69,6 +70,41 @@ describe('TracingList', () => {
expect(findTableList().props('traces')).toEqual(mockResponse.traces);
});
describe('traces and analytics API requests', () => {
let resolveFetchTraces;
let resolveFetchTracesAnalytics;
beforeEach(async () => {
const fetchTracesPromise = new Promise((resolve) => {
resolveFetchTraces = resolve;
});
const fetchTracesAnalyticsPromise = new Promise((resolve) => {
resolveFetchTracesAnalytics = resolve;
});
observabilityClientMock.fetchTraces.mockReturnValue(fetchTracesPromise);
observabilityClientMock.fetchTracesAnalytics.mockReturnValue(fetchTracesAnalyticsPromise);
await mountComponent();
});
it('fetches traces and analytics in parallel', () => {
expect(observabilityClientMock.fetchTracesAnalytics).toHaveBeenCalled();
expect(observabilityClientMock.fetchTraces).toHaveBeenCalled();
});
it('waits for both requests to complete', async () => {
expect(findLoadingIcon().exists()).toBe(true);
resolveFetchTraces();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(true);
resolveFetchTracesAnalytics();
await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('on trace-clicked', () => {
let visitUrlMock;
beforeEach(() => {
......@@ -175,29 +211,38 @@ describe('TracingList', () => {
});
});
it('fetches traces with filters and sort order', () => {
it('fetches traces and analytics with options', () => {
const expectedFilters = {
period: [{ operator: '=', value: '4h' }],
service: [
{ operator: '=', value: 'loadgenerator' },
{ operator: '=', value: 'test-service' },
],
operation: [{ operator: '=', value: 'test-op' }],
traceId: [{ operator: '=', value: 'test_trace' }],
durationMs: [{ operator: '>', value: '100' }],
attribute: [{ operator: '=', value: 'foo=bar' }],
status: [{ operator: '=', value: 'ok' }],
search: undefined,
};
expect(observabilityClientMock.fetchTraces).toHaveBeenLastCalledWith({
filters: {
period: [{ operator: '=', value: '4h' }],
service: [
{ operator: '=', value: 'loadgenerator' },
{ operator: '=', value: 'test-service' },
],
operation: [{ operator: '=', value: 'test-op' }],
traceId: [{ operator: '=', value: 'test_trace' }],
durationMs: [{ operator: '>', value: '100' }],
attribute: [{ operator: '=', value: 'foo=bar' }],
status: [{ operator: '=', value: 'ok' }],
search: undefined,
...expectedFilters,
},
pageSize: 500,
pageToken: null,
sortBy: 'duration_desc',
});
expect(observabilityClientMock.fetchTracesAnalytics).toHaveBeenLastCalledWith({
filters: {
...expectedFilters,
},
});
});
describe('on search submit', () => {
beforeEach(async () => {
observabilityClientMock.fetchTracesAnalytics.mockReset();
await setFilters({
period: [{ operator: '=', value: '12h' }],
service: [{ operator: '=', value: 'frontend' }],
......@@ -233,21 +278,30 @@ describe('TracingList', () => {
});
});
it('fetches traces with updated filters', () => {
it('fetches traces and analytics with updated filters', () => {
const expectedFilters = {
period: [{ operator: '=', value: '12h' }],
service: [{ operator: '=', value: 'frontend' }],
operation: [{ operator: '=', value: 'op' }],
traceId: [{ operator: '=', value: 'another_trace' }],
durationMs: [{ operator: '>', value: '200' }],
attribute: [{ operator: '=', value: 'foo=baz' }],
status: [{ operator: '=', value: 'error' }],
};
expect(observabilityClientMock.fetchTraces).toHaveBeenLastCalledWith({
filters: {
period: [{ operator: '=', value: '12h' }],
service: [{ operator: '=', value: 'frontend' }],
operation: [{ operator: '=', value: 'op' }],
traceId: [{ operator: '=', value: 'another_trace' }],
durationMs: [{ operator: '>', value: '200' }],
attribute: [{ operator: '=', value: 'foo=baz' }],
status: [{ operator: '=', value: 'error' }],
...expectedFilters,
},
pageSize: 500,
pageToken: null,
sortBy: 'duration_desc',
});
expect(observabilityClientMock.fetchTracesAnalytics).toHaveBeenLastCalledWith({
filters: {
...expectedFilters,
},
});
});
it('updates FilteredSearch initialFilters', () => {
......@@ -267,20 +321,25 @@ describe('TracingList', () => {
it('sets the 1h period filter if not specified otherwise', async () => {
await setFilters({});
const expectedFilters = {
period: [{ operator: '=', value: '1h' }],
service: undefined,
operation: undefined,
traceId: undefined,
durationMs: undefined,
attribute: undefined,
status: undefined,
};
expect(observabilityClientMock.fetchTraces).toHaveBeenLastCalledWith({
filters: {
period: [{ operator: '=', value: '1h' }],
service: undefined,
operation: undefined,
traceId: undefined,
durationMs: undefined,
attribute: undefined,
status: undefined,
},
filters: { ...expectedFilters },
pageSize: 500,
pageToken: null,
sortBy: 'duration_desc',
});
expect(observabilityClientMock.fetchTracesAnalytics).toHaveBeenLastCalledWith({
filters: { ...expectedFilters },
});
expect(findFilteredSearch().props('initialFilters')).toEqual(
filterObjToFilterToken({
......@@ -323,6 +382,8 @@ describe('TracingList', () => {
setWindowLocation('?sortBy=duration_desc');
await mountComponent();
observabilityClientMock.fetchTracesAnalytics.mockReset();
findFilteredSearch().vm.$emit('sort', 'timestamp_asc');
await waitForPromises();
});
......@@ -350,6 +411,10 @@ describe('TracingList', () => {
});
});
it('does not fetch analytics', () => {
expect(observabilityClientMock.fetchTracesAnalytics).not.toHaveBeenCalled();
});
it('updates FilteredSearch initial sort', () => {
expect(findFilteredSearch().props('initialSort')).toEqual('timestamp_asc');
});
......@@ -405,6 +470,14 @@ describe('TracingList', () => {
]);
});
it('does not fetch analytics when bottom reached', async () => {
observabilityClientMock.fetchTracesAnalytics.mockReset();
await bottomReached();
expect(observabilityClientMock.fetchTracesAnalytics).not.toHaveBeenCalled();
});
it('does not update the next_page_token if missing - i.e. it reached the last page', async () => {
observabilityClientMock.fetchTraces.mockReturnValueOnce({
traces: [],
......@@ -457,25 +530,31 @@ describe('TracingList', () => {
await setFilters({ period: [{ operator: '=', value: '4h' }] });
const expectedFilters = {
attribute: undefined,
durationMs: undefined,
operation: undefined,
period: [{ operator: '=', value: '4h' }],
search: undefined,
service: undefined,
traceId: undefined,
};
expect(observabilityClientMock.fetchTraces).toHaveBeenLastCalledWith({
filters: {
attribute: undefined,
durationMs: undefined,
operation: undefined,
period: [{ operator: '=', value: '4h' }],
search: undefined,
service: undefined,
traceId: undefined,
},
filters: { ...expectedFilters },
pageSize: 500,
pageToken: null,
sortBy: 'duration_desc',
});
expect(observabilityClientMock.fetchTracesAnalytics).toHaveBeenCalledWith({
filters: { ...expectedFilters },
});
expect(findTableList().props('traces')).toEqual(mockResponse.traces);
});
it('when sort order is changed, pagination and traces are reset', async () => {
observabilityClientMock.fetchTracesAnalytics.mockReset();
observabilityClientMock.fetchTraces.mockReturnValueOnce({
traces: [{ trace_id: 'trace-3' }],
next_page_token: 'page-3',
......@@ -499,6 +578,7 @@ describe('TracingList', () => {
pageToken: null,
sortBy: 'duration_asc',
});
expect(observabilityClientMock.fetchTracesAnalytics).not.toHaveBeenCalled();
expect(findTableList().props('traces')).toEqual(mockResponse.traces);
});
......@@ -577,5 +657,15 @@ describe('TracingList', () => {
expect(findTableList().exists()).toBe(true);
expect(findTableList().props('traces')).toEqual([]);
});
it('if fetchTracesAnalytics fails, it renders an alert and empty list', async () => {
observabilityClientMock.fetchTracesAnalytics.mockRejectedValue('error');
await mountComponent();
expect(createAlert).toHaveBeenLastCalledWith({ message: 'Failed to load traces.' });
expect(findTableList().exists()).toBe(true);
expect(findTableList().props('traces')).toEqual([]);
});
});
});
......@@ -14,6 +14,7 @@
oauthUrl: Gitlab::Observability.oauth_url,
provisioningUrl: Gitlab::Observability.provisioning_url(project),
tracingUrl: Gitlab::Observability.tracing_url(project),
tracingAnalyticsUrl: Gitlab::Observability.tracing_analytics_url(project),
servicesUrl: Gitlab::Observability.services_url(project),
operationsUrl: Gitlab::Observability.operations_url(project),
metricsUrl: Gitlab::Observability.metrics_url(project),
......
......@@ -12,6 +12,12 @@
it { is_expected.to eq("#{described_class.observability_url}/v3/query/#{project.id}/traces") }
end
describe '.tracing_analytics_url' do
subject { described_class.tracing_analytics_url(project) }
it { is_expected.to eq("#{described_class.observability_url}/v3/query/#{project.id}/traces/analytics") }
end
describe '.services_url' do
subject { described_class.services_url(project) }
......
......@@ -13,6 +13,7 @@
oauthUrl: Gitlab::Observability.oauth_url,
provisioningUrl: Gitlab::Observability.provisioning_url(project),
tracingUrl: Gitlab::Observability.tracing_url(project),
tracingAnalyticsUrl: Gitlab::Observability.tracing_analytics_url(project),
servicesUrl: Gitlab::Observability.services_url(project),
operationsUrl: Gitlab::Observability.operations_url(project),
metricsUrl: Gitlab::Observability.metrics_url(project),
......
......@@ -13,6 +13,7 @@
oauthUrl: Gitlab::Observability.oauth_url,
provisioningUrl: Gitlab::Observability.provisioning_url(project),
tracingUrl: Gitlab::Observability.tracing_url(project),
tracingAnalyticsUrl: Gitlab::Observability.tracing_analytics_url(project),
servicesUrl: Gitlab::Observability.services_url(project),
operationsUrl: Gitlab::Observability.operations_url(project),
metricsUrl: Gitlab::Observability.metrics_url(project),
......
......@@ -4,6 +4,7 @@ export function createMockClient() {
const mockClient = buildClient({
provisioningUrl: 'provisioning-url',
tracingUrl: 'tracing-url',
tracingAnalyticsUrl: 'tracing-analytics-url',
servicesUrl: 'services-url',
operationsUrl: 'operations-url',
metricsUrl: 'metrics-url',
......
......@@ -14,6 +14,7 @@ describe('buildClient', () => {
let axiosMock;
const tracingUrl = 'https://example.com/tracing';
const tracingAnalyticsUrl = 'https://example.com/tracing/analytics';
const provisioningUrl = 'https://example.com/provisioning';
const servicesUrl = 'https://example.com/services';
const operationsUrl = 'https://example.com/services/$SERVICE_NAME$/operations';
......@@ -23,6 +24,7 @@ describe('buildClient', () => {
const apiConfig = {
tracingUrl,
tracingAnalyticsUrl,
provisioningUrl,
servicesUrl,
operationsUrl,
......@@ -389,6 +391,196 @@ describe('buildClient', () => {
});
});
describe('fetchTracesAnalytics', () => {
it('fetches analytics from the tracesAnalytics URL', async () => {
const mockResponse = {
results: [
{
Interval: 1705039800,
count: 5,
p90_duration_nano: 50613502867,
p95_duration_nano: 50613502867,
p75_duration_nano: 49756727928,
p50_duration_nano: 41610120929,
error_count: 324,
trace_rate: 2.576111111111111,
error_rate: 0.09,
},
],
};
axiosMock.onGet(tracingAnalyticsUrl).reply(200, mockResponse);
const result = await client.fetchTracesAnalytics();
expect(axios.get).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledWith(tracingAnalyticsUrl, {
withCredentials: true,
params: expect.any(URLSearchParams),
});
expect(result).toEqual(mockResponse.results);
});
it('returns empty array if analytics are missing', async () => {
axiosMock.onGet(tracingAnalyticsUrl).reply(200, {});
expect(await client.fetchTracesAnalytics()).toEqual([]);
});
describe('query filter', () => {
beforeEach(() => {
axiosMock.onGet(tracingAnalyticsUrl).reply(200, {
results: [],
});
});
it('does not set any query param without filters', async () => {
await client.fetchTracesAnalytics();
expect(getQueryParam()).toBe(``);
});
it('converts filter to proper query params', async () => {
await client.fetchTracesAnalytics({
filters: {
durationMs: [
{ operator: '>', value: '100' },
{ operator: '<', value: '1000' },
],
operation: [
{ operator: '=', value: 'op' },
{ operator: '!=', value: 'not-op' },
],
service: [
{ operator: '=', value: 'service' },
{ operator: '!=', value: 'not-service' },
],
period: [{ operator: '=', value: '5m' }],
status: [
{ operator: '=', value: 'ok' },
{ operator: '!=', value: 'error' },
],
traceId: [
{ operator: '=', value: 'trace-id' },
{ operator: '!=', value: 'not-trace-id' },
],
attribute: [{ operator: '=', value: 'name1=value1' }],
},
});
expect(getQueryParam()).toContain(
'gt[duration_nano]=100000000&lt[duration_nano]=1000000000' +
'&operation=op&not[operation]=not-op' +
'&service_name=service&not[service_name]=not-service' +
'&period=5m' +
'&trace_id=trace-id&not[trace_id]=not-trace-id' +
'&attr_name=name1&attr_value=value1' +
'&status=ok&not[status]=error',
);
});
describe('date range time filter', () => {
it('handles custom date range period filter', async () => {
await client.fetchTracesAnalytics({
filters: {
period: [{ operator: '=', value: '2023-01-01 - 2023-02-01' }],
},
});
expect(getQueryParam()).not.toContain('period=');
expect(getQueryParam()).toContain(
'start_time=2023-01-01T00:00:00.000Z&end_time=2023-02-01T00:00:00.000Z',
);
});
it.each([
'invalid - 2023-02-01',
'2023-02-01 - invalid',
'invalid - invalid',
'2023-01-01 / 2023-02-01',
'2023-01-01 2023-02-01',
'2023-01-01 - 2023-02-01 - 2023-02-01',
])('ignore invalid values', async (val) => {
await client.fetchTracesAnalytics({
filters: {
period: [{ operator: '=', value: val }],
},
});
expect(getQueryParam()).not.toContain('start_time=');
expect(getQueryParam()).not.toContain('end_time=');
expect(getQueryParam()).not.toContain('period=');
});
});
it('handles repeated params', async () => {
await client.fetchTracesAnalytics({
filters: {
operation: [
{ operator: '=', value: 'op' },
{ operator: '=', value: 'op2' },
],
},
});
expect(getQueryParam()).toContain('operation=op&operation=op2');
});
it('ignores unsupported filters', async () => {
await client.fetchTracesAnalytics({
filters: {
unsupportedFilter: [{ operator: '=', value: 'foo' }],
},
});
expect(getQueryParam()).toBe(``);
});
it('ignores empty filters', async () => {
await client.fetchTracesAnalytics({
filters: {
durationMs: null,
},
});
expect(getQueryParam()).toBe(``);
});
it('ignores non-array filters', async () => {
await client.fetchTracesAnalytics({
filters: {
traceId: { operator: '=', value: 'foo' },
},
});
expect(getQueryParam()).toBe(``);
});
it('ignores unsupported operators', async () => {
await client.fetchTracesAnalytics({
filters: {
durationMs: [
{ operator: '*', value: 'foo' },
{ operator: '=', value: 'foo' },
{ operator: '!=', value: 'foo' },
],
operation: [
{ operator: '>', value: 'foo' },
{ operator: '<', value: 'foo' },
],
service: [
{ operator: '>', value: 'foo' },
{ operator: '<', value: 'foo' },
],
period: [{ operator: '!=', value: 'foo' }],
traceId: [
{ operator: '>', value: 'foo' },
{ operator: '<', value: 'foo' },
],
},
});
expect(getQueryParam()).toBe(``);
});
});
});
describe('fetchServices', () => {
it('fetches services from the services URL', async () => {
const mockResponse = {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment