Show traceids for failing and successful requests (#64903)

* tracing: show backend trace ids in frontend

* better trace id naming

Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>

* better trace id naming

Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>

* better trace id naming

Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>

* added feature flag

* bind functionality to the feature flag

* use non-generic name for traceid header

* fixed tests

* loki: do not create empty fields

* do not add empty fields

* fixed graphite test mock data

* added unit-tests to queryResponse

* added unit-tests for backend_srv

* more typescript-friendly check

* added unit-tests for runRequest

---------

Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>
This commit is contained in:
Gábor Farkas 2023-04-05 09:13:24 +02:00 committed by GitHub
parent 9719ee9bd3
commit 4ded937c79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 327 additions and 13 deletions

View File

@ -84,6 +84,7 @@ Alpha features might be changed or removed without prior notice.
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema | | `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query | | `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
| `elasticsearchBackendMigration` | Use Elasticsearch as backend data source | | `elasticsearchBackendMigration` | Use Elasticsearch as backend data source |
| `showTraceId` | Show trace ids for requests |
| `datasourceOnboarding` | Enable data source onboarding page | | `datasourceOnboarding` | Enable data source onboarding page |
| `secureSocksDatasourceProxy` | Enable secure socks tunneling for supported core datasources | | `secureSocksDatasourceProxy` | Enable secure socks tunneling for supported core datasources |
| `authnService` | Use new auth service to perform authentication | | `authnService` | Use new auth service to perform authentication |

View File

@ -465,6 +465,11 @@ export interface DataQueryResponse {
* Defaults to LoadingState.Done if state is not defined * Defaults to LoadingState.Done if state is not defined
*/ */
state?: LoadingState; state?: LoadingState;
/**
* traceIds related to the response, if available
*/
traceIds?: string[];
} }
export enum DataQueryErrorType { export enum DataQueryErrorType {
@ -488,6 +493,7 @@ export interface DataQueryError {
status?: number; status?: number;
statusText?: string; statusText?: string;
refId?: string; refId?: string;
traceId?: string;
type?: DataQueryErrorType; type?: DataQueryErrorType;
} }

View File

@ -65,6 +65,7 @@ export interface FeatureToggles {
nestedFolders?: boolean; nestedFolders?: boolean;
accessTokenExpirationCheck?: boolean; accessTokenExpirationCheck?: boolean;
elasticsearchBackendMigration?: boolean; elasticsearchBackendMigration?: boolean;
showTraceId?: boolean;
datasourceOnboarding?: boolean; datasourceOnboarding?: boolean;
emptyDashboardPage?: boolean; emptyDashboardPage?: boolean;
secureSocksDatasourceProxy?: boolean; secureSocksDatasourceProxy?: boolean;

View File

@ -66,6 +66,9 @@ export interface PanelData {
/** Contains the range from the request or a shifted time range if a request uses relative time */ /** Contains the range from the request or a shifted time range if a request uses relative time */
timeRange: TimeRange; timeRange: TimeRange;
/** traceIds collected during the processing of the requests */
traceIds?: string[];
} }
export interface PanelProps<T = any> { export interface PanelProps<T = any> {

View File

@ -96,6 +96,7 @@ export interface FetchResponse<T = any> {
readonly type: ResponseType; readonly type: ResponseType;
readonly url: string; readonly url: string;
readonly config: BackendSrvRequest; readonly config: BackendSrvRequest;
readonly traceId?: string;
} }
/** /**
@ -122,6 +123,7 @@ export interface FetchError<T = any> {
cancelled?: boolean; cancelled?: boolean;
isHandled?: boolean; isHandled?: boolean;
config: BackendSrvRequest; config: BackendSrvRequest;
traceId?: string;
} }
export function isFetchError(e: unknown): e is FetchError { export function isFetchError(e: unknown): e is FetchError {

View File

@ -280,6 +280,82 @@ describe('Query Response parser', () => {
expect(ids).toEqual(['A', 'B']); expect(ids).toEqual(['A', 'B']);
}); });
test('should handle a success-response without traceIds', () => {
const input = {
data: {
results: {
A: {
frames: [],
},
},
},
} as unknown as FetchResponse<BackendDataSourceResponse>;
const res = toDataQueryResponse(input);
expect(res.traceIds).toBeUndefined();
});
test('should handle a success-response with traceIds', () => {
const input = {
data: {
results: {
A: {
frames: [],
},
},
},
traceId: 'traceId1',
} as unknown as FetchResponse<BackendDataSourceResponse>;
const res = toDataQueryResponse(input);
expect(res.traceIds).toStrictEqual(['traceId1']);
});
test('should handle an error-response without traceIds', () => {
const input = {
data: {
results: {
A: {
error: 'error from A',
status: 400,
},
B: {
error: 'error from B',
status: 400,
},
},
},
} as unknown as FetchResponse<BackendDataSourceResponse>;
const res = toDataQueryResponse(input);
expect(res.traceIds).toBeUndefined();
expect(res.error?.traceId).toBeUndefined();
expect(res.errors).toHaveLength(2);
expect(res.errors?.[0].traceId).toBeUndefined();
expect(res.errors?.[1].traceId).toBeUndefined();
});
test('should handle an error-response with traceIds', () => {
const input = {
data: {
results: {
A: {
error: 'error from A',
status: 400,
},
B: {
error: 'error from B',
status: 400,
},
},
},
traceId: 'traceId1',
} as unknown as FetchResponse<BackendDataSourceResponse>;
const res = toDataQueryResponse(input);
expect(res.traceIds).toStrictEqual(['traceId1']);
expect(res.error?.traceId).toBe('traceId1');
expect(res.errors).toHaveLength(2);
expect(res.errors?.[0].traceId).toBe('traceId1');
expect(res.errors?.[1].traceId).toBe('traceId1');
});
describe('Cache notice', () => { describe('Cache notice', () => {
let resp: FetchResponse<BackendDataSourceResponse>; let resp: FetchResponse<BackendDataSourceResponse>;

View File

@ -65,6 +65,13 @@ export function toDataQueryResponse(
queries?: DataQuery[] queries?: DataQuery[]
): DataQueryResponse { ): DataQueryResponse {
const rsp: DataQueryResponse = { data: [], state: LoadingState.Done }; const rsp: DataQueryResponse = { data: [], state: LoadingState.Done };
const traceId = 'traceId' in res ? res.traceId : undefined;
if (traceId != null) {
rsp.traceIds = [traceId];
}
// If the response isn't in a correct shape we just ignore the data and pass empty DataQueryResponse. // If the response isn't in a correct shape we just ignore the data and pass empty DataQueryResponse.
if ((res as FetchResponse).data?.results) { if ((res as FetchResponse).data?.results) {
const results = (res as FetchResponse).data.results; const results = (res as FetchResponse).data.results;
@ -83,17 +90,21 @@ export function toDataQueryResponse(
for (const dr of data) { for (const dr of data) {
if (dr.error) { if (dr.error) {
const errorObj: DataQueryError = {
refId: dr.refId,
message: dr.error,
status: dr.status,
};
if (traceId != null) {
errorObj.traceId = traceId;
}
if (!rsp.error) { if (!rsp.error) {
rsp.error = { rsp.error = { ...errorObj };
refId: dr.refId,
message: dr.error,
status: dr.status,
};
} }
if (rsp.errors) { if (rsp.errors) {
rsp.errors.push({ refId: dr.refId, message: dr.error, status: dr.status }); rsp.errors.push({ ...errorObj });
} else { } else {
rsp.errors = [{ refId: dr.refId, message: dr.error, status: dr.status }]; rsp.errors = [{ ...errorObj }];
} }
rsp.state = LoadingState.Error; rsp.state = LoadingState.Error;
} }

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/grafana/grafana/pkg/infra/tracing"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
@ -29,12 +30,18 @@ func AddDefaultResponseHeaders(cfg *setting.Cfg) web.Handler {
t := web.NewTree() t := web.NewTree()
t.Add("/api/datasources/uid/:uid/resources/*", nil) t.Add("/api/datasources/uid/:uid/resources/*", nil)
t.Add("/api/datasources/:id/resources/*", nil) t.Add("/api/datasources/:id/resources/*", nil)
return func(c *web.Context) { return func(c *web.Context) {
c.Resp.Before(func(w web.ResponseWriter) { // if response has already been written, skip. c.Resp.Before(func(w web.ResponseWriter) { // if response has already been written, skip.
if w.Written() { if w.Written() {
return return
} }
traceId := tracing.TraceIDFromContext(c.Req.Context(), false)
if traceId != "" {
w.Header().Set("grafana-trace-id", traceId)
}
_, _, resourceURLMatch := t.Match(c.Req.URL.Path) _, _, resourceURLMatch := t.Match(c.Req.URL.Path)
resourceCachable := resourceURLMatch && allowCacheControl(c.Resp) resourceCachable := resourceURLMatch && allowCacheControl(c.Resp)
if !strings.HasPrefix(c.Req.URL.Path, "/public/plugins/") && if !strings.HasPrefix(c.Req.URL.Path, "/public/plugins/") &&

View File

@ -315,6 +315,12 @@ var (
State: FeatureStateAlpha, State: FeatureStateAlpha,
Owner: grafanaObservabilityLogsSquad, Owner: grafanaObservabilityLogsSquad,
}, },
{
Name: "showTraceId",
Description: "Show trace ids for requests",
State: FeatureStateAlpha,
Owner: grafanaObservabilityLogsSquad,
},
{ {
Name: "datasourceOnboarding", Name: "datasourceOnboarding",
Description: "Enable data source onboarding page", Description: "Enable data source onboarding page",

View File

@ -46,6 +46,7 @@ accessControlOnCall,beta,@grafana/grafana-authnz-team,false,false,false,false
nestedFolders,alpha,@grafana/backend-platform,true,false,false,false nestedFolders,alpha,@grafana/backend-platform,true,false,false,false
accessTokenExpirationCheck,stable,@grafana/grafana-authnz-team,false,false,false,false accessTokenExpirationCheck,stable,@grafana/grafana-authnz-team,false,false,false,false
elasticsearchBackendMigration,alpha,@grafana/observability-logs,false,false,false,false elasticsearchBackendMigration,alpha,@grafana/observability-logs,false,false,false,false
showTraceId,alpha,@grafana/observability-logs,false,false,false,false
datasourceOnboarding,alpha,@grafana/dashboards-squad,false,false,false,false datasourceOnboarding,alpha,@grafana/dashboards-squad,false,false,false,false
emptyDashboardPage,stable,@grafana/dashboards-squad,false,false,false,true emptyDashboardPage,stable,@grafana/dashboards-squad,false,false,false,true
secureSocksDatasourceProxy,alpha,@grafana/hosted-grafana-team,false,false,false,false secureSocksDatasourceProxy,alpha,@grafana/hosted-grafana-team,false,false,false,false

1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
46 nestedFolders alpha @grafana/backend-platform true false false false
47 accessTokenExpirationCheck stable @grafana/grafana-authnz-team false false false false
48 elasticsearchBackendMigration alpha @grafana/observability-logs false false false false
49 showTraceId alpha @grafana/observability-logs false false false false
50 datasourceOnboarding alpha @grafana/dashboards-squad false false false false
51 emptyDashboardPage stable @grafana/dashboards-squad false false false true
52 secureSocksDatasourceProxy alpha @grafana/hosted-grafana-team false false false false

View File

@ -195,6 +195,10 @@ const (
// Use Elasticsearch as backend data source // Use Elasticsearch as backend data source
FlagElasticsearchBackendMigration = "elasticsearchBackendMigration" FlagElasticsearchBackendMigration = "elasticsearchBackendMigration"
// FlagShowTraceId
// Show trace ids for requests
FlagShowTraceId = "showTraceId"
// FlagDatasourceOnboarding // FlagDatasourceOnboarding
// Enable data source onboarding page // Enable data source onboarding page
FlagDatasourceOnboarding = "datasourceOnboarding" FlagDatasourceOnboarding = "datasourceOnboarding"

View File

@ -50,6 +50,8 @@ export interface FolderRequestOptions {
withAccessControl?: boolean; withAccessControl?: boolean;
} }
const GRAFANA_TRACEID_HEADER = 'grafana-trace-id';
export class BackendSrv implements BackendService { export class BackendSrv implements BackendService {
private inFlightRequests: Subject<string> = new Subject<string>(); private inFlightRequests: Subject<string> = new Subject<string>();
private HTTP_REQUEST_CANCELED = -1; private HTTP_REQUEST_CANCELED = -1;
@ -223,6 +225,7 @@ export class BackendSrv implements BackendService {
type, type,
redirected, redirected,
config: options, config: options,
traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined,
}; };
return fetchResponse; return fetchResponse;
}), }),
@ -238,7 +241,13 @@ export class BackendSrv implements BackendService {
filter((response) => response.ok === false), filter((response) => response.ok === false),
mergeMap((response) => { mergeMap((response) => {
const { status, statusText, data } = response; const { status, statusText, data } = response;
const fetchErrorResponse: FetchError = { status, statusText, data, config: options }; const fetchErrorResponse: FetchError = {
status,
statusText,
data,
config: options,
traceId: response.headers.get(GRAFANA_TRACEID_HEADER) ?? undefined,
};
return throwError(fetchErrorResponse); return throwError(fetchErrorResponse);
}), }),
retryWhen((attempts: Observable<any>) => retryWhen((attempts: Observable<any>) =>

View File

@ -1,5 +1,5 @@
import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering
import { Observable, of } from 'rxjs'; import { Observable, of, lastValueFrom } from 'rxjs';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { AppEvents, DataQueryErrorType, EventBusExtended } from '@grafana/data'; import { AppEvents, DataQueryErrorType, EventBusExtended } from '@grafana/data';
@ -21,6 +21,7 @@ const getTestContext = (overides?: object) => {
redirected: false, redirected: false,
type: 'basic', type: 'basic',
url: 'http://localhost:3000/api/some-mock', url: 'http://localhost:3000/api/some-mock',
headers: new Map(),
}; };
const props = { ...defaults, ...overides }; const props = { ...defaults, ...overides };
const textMock = jest.fn().mockResolvedValue(JSON.stringify(props.data)); const textMock = jest.fn().mockResolvedValue(JSON.stringify(props.data));
@ -29,6 +30,7 @@ const getTestContext = (overides?: object) => {
ok: props.ok, ok: props.ok,
status: props.status, status: props.status,
statusText: props.statusText, statusText: props.statusText,
headers: props.headers,
text: textMock, text: textMock,
redirected: false, redirected: false,
type: 'basic', type: 'basic',
@ -355,6 +357,63 @@ describe('backendSrv', () => {
}); });
}); });
}); });
describe('traceId handling', () => {
const opts = { url: '/something', method: 'GET' };
it('should handle a success-response without traceId', async () => {
const ctx = getTestContext({ status: 200, statusText: 'OK', headers: new Headers() });
const res = await lastValueFrom(ctx.backendSrv.fetch(opts));
expect(res.traceId).toBeUndefined();
});
it('should handle a success-response with traceId', async () => {
const ctx = getTestContext({
status: 200,
statusText: 'OK',
headers: new Headers({
'grafana-trace-id': 'traceId1',
}),
});
const res = await lastValueFrom(ctx.backendSrv.fetch(opts));
expect(res.traceId).toBe('traceId1');
});
it('should handle an error-response without traceId', () => {
const ctx = getTestContext({
ok: false,
status: 500,
statusText: 'INTERNAL SERVER ERROR',
headers: new Headers(),
});
return lastValueFrom(ctx.backendSrv.fetch(opts)).then(
(data) => {
throw new Error('must not get here');
},
(error) => {
expect(error.traceId).toBeUndefined();
}
);
});
it('should handle an error-response with traceId', () => {
const ctx = getTestContext({
ok: false,
status: 500,
statusText: 'INTERNAL SERVER ERROR',
headers: new Headers({
'grafana-trace-id': 'traceId1',
}),
});
return lastValueFrom(ctx.backendSrv.fetch(opts)).then(
(data) => {
throw new Error('must not get here');
},
(error) => {
expect(error.traceId).toBe('traceId1');
}
);
});
});
}); });
describe('datasourceRequest', () => { describe('datasourceRequest', () => {
@ -369,6 +428,7 @@ describe('backendSrv', () => {
ok: true, ok: true,
status: 200, status: 200,
statusText: 'Ok', statusText: 'Ok',
headers: new Map(),
text: () => Promise.resolve(JSON.stringify(slowData)), text: () => Promise.resolve(JSON.stringify(slowData)),
redirected: false, redirected: false,
type: 'basic', type: 'basic',
@ -382,6 +442,7 @@ describe('backendSrv', () => {
ok: true, ok: true,
status: 200, status: 200,
statusText: 'Ok', statusText: 'Ok',
headers: new Map(),
text: () => Promise.resolve(JSON.stringify(fastData)), text: () => Promise.resolve(JSON.stringify(fastData)),
redirected: false, redirected: false,
type: 'basic', type: 'basic',

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { DataQueryError } from '@grafana/data'; import { DataQueryError } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, JSONFormatter } from '@grafana/ui'; import { Alert, JSONFormatter } from '@grafana/ui';
interface InspectErrorTabProps { interface InspectErrorTabProps {
@ -36,6 +37,12 @@ function renderError(error: DataQueryError) {
<> <>
{error.status && <>Status: {error.status}. Message: </>} {error.status && <>Status: {error.status}. Message: </>}
{msg} {msg}
{config.featureToggles.showTraceId && error.traceId != null && (
<>
<br />
(Trace ID: {error.traceId})
</>
)}
</> </>
); );
} else { } else {

View File

@ -2,9 +2,11 @@ import React from 'react';
import { PanelData, QueryResultMetaStat, TimeZone } from '@grafana/data'; import { PanelData, QueryResultMetaStat, TimeZone } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { InspectStatsTable } from './InspectStatsTable'; import { InspectStatsTable } from './InspectStatsTable';
import { InspectStatsTraceIdsTable } from './InspectStatsTraceIdsTable';
interface InspectStatsTabProps { interface InspectStatsTabProps {
data: PanelData; data: PanelData;
@ -59,11 +61,15 @@ export const InspectStatsTab = ({ data, timeZone }: InspectStatsTabProps) => {
const statsTableName = t('dashboard.inspect-stats.table-title', 'Stats'); const statsTableName = t('dashboard.inspect-stats.table-title', 'Stats');
const dataStatsTableName = t('dashboard.inspect-stats.data-title', 'Data source stats'); const dataStatsTableName = t('dashboard.inspect-stats.data-title', 'Data source stats');
const traceIdsStatsTableName = t('dashboard.inspect-stats.data-traceids', 'Trace IDs');
return ( return (
<div aria-label={selectors.components.PanelInspector.Stats.content}> <div aria-label={selectors.components.PanelInspector.Stats.content}>
<InspectStatsTable timeZone={timeZone} name={statsTableName} stats={stats} /> <InspectStatsTable timeZone={timeZone} name={statsTableName} stats={stats} />
<InspectStatsTable timeZone={timeZone} name={dataStatsTableName} stats={dataStats} /> <InspectStatsTable timeZone={timeZone} name={dataStatsTableName} stats={dataStats} />
{config.featureToggles.showTraceId && (
<InspectStatsTraceIdsTable name={traceIdsStatsTableName} traceIds={data.traceIds ?? []} />
)}
</div> </div>
); );
}; };

View File

@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { stylesFactory, useTheme2 } from '@grafana/ui';
type Props = {
name: string;
traceIds: string[];
};
export const InspectStatsTraceIdsTable = ({ name, traceIds }: Props) => {
const theme = useTheme2();
const styles = getStyles(theme);
if (traceIds.length === 0) {
return null;
}
return (
<div className={styles.wrapper}>
<div className="section-heading">{name}</div>
<table className="filter-table width-30">
<tbody>
{traceIds.map((traceId, index) => {
return (
<tr key={`${traceId}-${index}`}>
<td>{traceId}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
wrapper: css`
padding-bottom: ${theme.spacing(2)};
`,
cell: css`
text-align: right;
`,
};
});

View File

@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { DataQueryError, GrafanaTheme2 } from '@grafana/data'; import { DataQueryError, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Icon, useStyles2 } from '@grafana/ui'; import { Icon, useStyles2 } from '@grafana/ui';
export interface Props { export interface Props {
@ -18,7 +19,14 @@ export function QueryErrorAlert({ error }: Props) {
<div className={styles.icon}> <div className={styles.icon}>
<Icon name="exclamation-triangle" /> <Icon name="exclamation-triangle" />
</div> </div>
<div className={styles.message}>{message}</div> <div className={styles.message}>
{message}
{config.featureToggles.showTraceId && error.traceId != null && (
<>
<br /> <span>(Trace ID: {error.traceId})</span>
</>
)}
</div>
</div> </div>
); );
} }

View File

@ -223,6 +223,49 @@ describe('runRequest', () => {
}); });
}); });
runRequestScenario('When the response contains traceIds', (ctx) => {
ctx.setup(() => {
ctx.start();
ctx.emitPacket({
data: [{ name: 'data-a', refId: 'A' } as DataFrame],
});
ctx.emitPacket({
data: [{ name: 'data-b', refId: 'B' } as DataFrame],
});
ctx.emitPacket({
data: [{ name: 'data-c', refId: 'C' } as DataFrame],
traceIds: ['t1', 't2'],
});
ctx.emitPacket({
data: [{ name: 'data-d', refId: 'D' } as DataFrame],
});
ctx.emitPacket({
data: [{ name: 'data-e', refId: 'E' } as DataFrame],
traceIds: ['t3', 't4'],
});
ctx.emitPacket({
data: [{ name: 'data-e', refId: 'E' } as DataFrame],
traceIds: ['t4', 't4'],
});
});
it('should collect traceIds correctly', () => {
const { results } = ctx;
expect(results).toHaveLength(6);
expect(results[0].traceIds).toBeUndefined();
// this is the result of adding no-traces data to no-traces state
expect(results[1].traceIds).toBeUndefined();
// this is the result of adding with-traces data to no-traces state
expect(results[2].traceIds).toStrictEqual(['t1', 't2']);
// this is the result of adding no-traces data to with-traces state
expect(results[3].traceIds).toStrictEqual(['t1', 't2']);
// this is the result of adding with-traces data to with-traces state
expect(results[4].traceIds).toStrictEqual(['t1', 't2', 't3', 't4']);
// this is the result of adding with-traces data to with-traces state with duplicate traceIds
expect(results[5].traceIds).toStrictEqual(['t1', 't2', 't3', 't4']);
});
});
runRequestScenario('After response with state Streaming', (ctx) => { runRequestScenario('After response with state Streaming', (ctx) => {
ctx.setup(() => { ctx.setup(() => {
ctx.start(); ctx.start();

View File

@ -88,6 +88,13 @@ export function processResponsePacket(packet: DataQueryResponse, state: RunningQ
timeRange, timeRange,
}; };
// we use a Set to deduplicate the traceIds
const traceIdSet = new Set([...(state.panelData.traceIds ?? []), ...(packet.traceIds ?? [])]);
if (traceIdSet.size > 0) {
panelData.traceIds = Array.from(traceIdSet);
}
return { packets, panelData }; return { packets, panelData };
} }

View File

@ -116,12 +116,12 @@ function mockBackendSrv(data: string) {
redirected: false, redirected: false,
type: 'basic', type: 'basic',
url: 'http://localhost:3000/api/some-mock', url: 'http://localhost:3000/api/some-mock',
headers: { headers: new Headers({
method: 'GET', method: 'GET',
url: '/functions', url: '/functions',
// to work around Graphite returning invalid JSON // to work around Graphite returning invalid JSON
responseType: 'text', responseType: 'text',
}, }),
}; };
return of(mockedResponse); return of(mockedResponse);
}); });

View File

@ -154,7 +154,15 @@ export function combineResponses(currentResult: DataQueryResponse | null, newRes
// some grafana parts do not behave well. // some grafana parts do not behave well.
// we just choose the old error, if it exists, // we just choose the old error, if it exists,
// otherwise the new error, if it exists. // otherwise the new error, if it exists.
currentResult.error = currentResult.error ?? newResult.error; const mergedError = currentResult.error ?? newResult.error;
if (mergedError != null) {
currentResult.error = mergedError;
}
const mergedTraceIds = [...(currentResult.traceIds ?? []), ...(newResult.traceIds ?? [])];
if (mergedTraceIds.length > 0) {
currentResult.traceIds = mergedTraceIds;
}
return currentResult; return currentResult;
} }