diff --git a/src/DataSource.test.ts b/src/DataSource.test.ts index 2c56fdf..33c7a50 100644 --- a/src/DataSource.test.ts +++ b/src/DataSource.test.ts @@ -68,7 +68,7 @@ describe('XrayDataSource', () => { const ds = makeDatasourceWithResponse(makeTraceResponse(makeTrace())); const response = await firstValueFrom(ds.query(makeQuery())); expect(response.data.length).toBe(1); - expect(response.data[0].fields.length).toBe(13); + expect(response.data[0].fields.length).toBe(15); expect(response.data[0].fields[0].values.length).toBe(2); }); diff --git a/src/DataSource.ts b/src/DataSource.ts index 4444fe2..4a9d439 100644 --- a/src/DataSource.ts +++ b/src/DataSource.ts @@ -12,7 +12,7 @@ import { } from '@grafana/data'; import { DataSourceWithBackend, getTemplateSrv, TemplateSrv, config } from '@grafana/runtime'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { mergeMap } from 'rxjs/operators'; import { Group, @@ -26,7 +26,7 @@ import { } from './types'; import { parseGraphResponse, transformTraceResponse } from 'utils/transform'; import { XRayLanguageProvider } from 'language_provider'; -import { makeLinks } from './utils/links'; +import { addTraceToLogsLinks, makeServiceMapLinks } from './utils/links'; export class XrayDataSource extends DataSourceWithBackend { private instanceSettings: DataSourceInstanceSettings; @@ -43,13 +43,17 @@ export class XrayDataSource extends DataSourceWithBackend { - return { - ...dataQueryResponse, - data: dataQueryResponse.data.flatMap((frame) => { + mergeMap(async (dataQueryResponse) => { + const parsedData = await Promise.all( + dataQueryResponse.data.map((frame) => { const target = request.targets.find((t) => t.refId === frame.refId); return this.parseResponse(frame, target); - }), + }) + ); + + return { + ...dataQueryResponse, + data: parsedData.flat(), }; }) ); @@ -158,11 +162,11 @@ export class XrayDataSource extends DataSourceWithBackend { // TODO this would better be based on type but backend Go def does not have dataFrame.type switch (response.name) { case 'Traces': - return parseTraceResponse(response, query); + return parseTraceResponse(response, query, this.instanceSettings.jsonData.tracesToLogs?.datasourceUid); case 'TraceSummaries': return parseTracesListResponse(response, this.instanceSettings, query); case 'InsightSummaries': @@ -219,7 +223,11 @@ function getDurationText(duration: DateTimeDuration) { The x-ray trace has a bit strange format where it comes as json and then some parts are string which also contains json, so some parts are escaped and we have to double parse that. */ -function parseTraceResponse(response: DataFrame, query?: XrayQuery): DataFrame[] { +async function parseTraceResponse( + response: DataFrame, + query: XrayQuery | undefined, + logsDatasourceUid: string | undefined +): Promise { // Again assuming this will ge single field with single value which will be the trace data blob const traceData = response.fields[0].values.get(0); const traceParsed: XrayTraceDataRaw = JSON.parse(traceData); @@ -236,6 +244,7 @@ function parseTraceResponse(response: DataFrame, query?: XrayQuery): DataFrame[] }; const frame = transformTraceResponse(traceParsedForReal); + await addTraceToLogsLinks(frame, query?.region, logsDatasourceUid); frame.refId = query?.refId; return [frame]; @@ -278,12 +287,12 @@ function parseServiceMapResponse( const [servicesFrame, edgesFrame] = parseGraphResponse(response, query); const serviceQuery = 'service(id(name: "${__data.fields.title}", type: "${__data.fields.subTitle}"))'; servicesFrame.fields[0].config = { - links: makeLinks(serviceQuery, instanceSettings, query), + links: makeServiceMapLinks(serviceQuery, instanceSettings, query), }; const edgeQuery = 'edge("${__data.fields.sourceName}", "${__data.fields.targetName}")'; edgesFrame.fields[0].config = { - links: makeLinks(edgeQuery, instanceSettings, query), + links: makeServiceMapLinks(edgeQuery, instanceSettings, query), }; return [servicesFrame, edgesFrame]; } diff --git a/src/components/ConfigEditor/ConfigEditor.tsx b/src/components/ConfigEditor/ConfigEditor.tsx index df365be..40a3442 100644 --- a/src/components/ConfigEditor/ConfigEditor.tsx +++ b/src/components/ConfigEditor/ConfigEditor.tsx @@ -1,16 +1,26 @@ -import { AwsAuthDataSourceJsonData, AwsAuthDataSourceSecureJsonData, ConnectionConfig } from '@grafana/aws-sdk'; -import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; +import { AwsAuthDataSourceSecureJsonData, ConnectionConfig } from '@grafana/aws-sdk'; +import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data'; import React, { PureComponent } from 'react'; +import { TraceToLogs } from './TraceToLogs'; +import { XrayJsonData } from '../../types'; import { standardRegions } from './regions'; -export type Props = DataSourcePluginOptionsEditorProps; +export type Props = DataSourcePluginOptionsEditorProps; export class ConfigEditor extends PureComponent { render() { + const { onOptionsChange, options } = this.props; return (
- {/* can add x-ray specific things here */} + + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToLogs', { + datasourceUid: uid, + }) + } + />
); } diff --git a/src/components/ConfigEditor/TraceToLogs.tsx b/src/components/ConfigEditor/TraceToLogs.tsx new file mode 100644 index 0000000..692a9f9 --- /dev/null +++ b/src/components/ConfigEditor/TraceToLogs.tsx @@ -0,0 +1,43 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; +import { DataSourcePicker } from '@grafana/runtime'; +import { InlineField, InlineFieldRow, useStyles } from '@grafana/ui'; +import React from 'react'; + +interface Props { + datasourceUid?: string; + onChange: (uid: string) => void; +} + +export function TraceToLogs({ datasourceUid, onChange }: Props) { + const styles = useStyles(getStyles); + + return ( +
+

Trace to logs

+ +
+ Trace to logs let's you navigate from a trace span to the selected data source's log. +
+ + + + onChange(ds.uid)} + /> + + +
+ ); +} + +const getStyles = (theme: GrafanaTheme) => ({ + infoText: css` + padding-bottom: ${theme.spacing.md}; + color: ${theme.colors.textSemiWeak}; + `, +}); diff --git a/src/types.ts b/src/types.ts index 0768b84..876c4a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,7 +56,9 @@ export enum XrayQueryType { } export interface XrayJsonData extends AwsAuthDataSourceJsonData { - // Can add X-Ray specific values here + tracesToLogs?: { + datasourceUid: string; + }; } export interface TSDBResponse { @@ -137,6 +139,12 @@ export interface AWS { version_label?: string; deployment_id?: number; }; + api_gateway: { + account_id: string; + request_id: string; + rest_api_id: string; + stage: string; + }; account_id?: string; retries?: number; region?: string; diff --git a/src/utils/links.ts b/src/utils/links.ts index ca86f04..e6c206b 100644 --- a/src/utils/links.ts +++ b/src/utils/links.ts @@ -1,7 +1,19 @@ import { XrayQuery, XrayQueryType } from '../types'; -import { DataSourceInstanceSettings } from '@grafana/data'; +import { DataFrame, DataLink, DataSourceInstanceSettings } from '@grafana/data'; +import { getDataSourceSrv } from '@grafana/runtime'; -export function makeLinks(itemQuery: string, instanceSettings: DataSourceInstanceSettings, dataQuery?: XrayQuery) { +/** + * Create links that will be shown when clicked on a service in service map. They target the same data source but + * queries for traces related to that particular service + * @param itemQuery String query part that should filter a particular service + * @param instanceSettings + * @param dataQuery Existing query to pass on any additional query data so they stay the same, like region. + */ +export function makeServiceMapLinks( + itemQuery: string, + instanceSettings: DataSourceInstanceSettings, + dataQuery?: XrayQuery +) { const makeLink = linkFactory(itemQuery, instanceSettings, dataQuery); return [ makeLink('Traces/All', XrayQueryType.getTraceSummaries), @@ -15,8 +27,52 @@ export function makeLinks(itemQuery: string, instanceSettings: DataSourceInstanc ]; } +export async function addTraceToLogsLinks(frame: DataFrame, region?: string, datasourceUid?: string) { + if (!datasourceUid) { + return; + } + try { + const spanField = frame.fields.find((f) => f.name === 'spanID')!; + const requestIdField = frame.fields.find((f) => f.name === '__request_id')!; + const link = await makeTraceToLogsLink(datasourceUid, region, { + hasRequestId: Boolean(requestIdField.values.get(0)), + }); + spanField.config.links = [link]; + } catch (error) { + // There are some things that can go wrong like datasourceUID not existing anymore etc. Does not seem useful to + // error the whole query in that case so we will just skip the links. + console.error(error); + } +} + +async function makeTraceToLogsLink( + datasourceUid: string, + region = 'default', + options: { hasRequestId: boolean } +): Promise { + const logsDS = await getDataSourceSrv().get(datasourceUid); + const filter = options.hasRequestId + ? 'filter @requestId = "${__data.fields.__request_id}" or @message like "${__data.fields.traceID}"' + : 'filter @message like "${__data.fields.traceID}"'; + return { + title: 'CloudWatch Logs', + url: '', + internal: { + query: { + region, + queryMode: 'Logs', + // Just use the data from the data frame. Needs to be filled in during transform. + logGroupNames: ['${__data.fields.__log_group}'], + expression: `fields @message | ${filter}`, + }, + datasourceUid, + datasourceName: logsDS.name, + }, + }; +} + function linkFactory(itemQuery: string, instanceSettings: DataSourceInstanceSettings, dataQuery?: XrayQuery) { - return (title: string, queryType: XrayQueryType, queryFilter?: string) => { + return (title: string, queryType: XrayQueryType, queryFilter?: string): DataLink => { return { title, url: '', diff --git a/src/utils/transform.test.ts b/src/utils/transform.test.ts index 99505db..2b039e7 100644 --- a/src/utils/transform.test.ts +++ b/src/utils/transform.test.ts @@ -284,9 +284,9 @@ const result = new MutableDataFrame({ values: [ '1-5ee20a4a-bab71b6bbc0660dba2adab3e', '1-5ee20a4a-bab71b6bbc0660dba2adab3e', + '', '1-5ee20a4a-bab71b6bbc0660dba2adab3e', '1-5ee20a4a-bab71b6bbc0660dba2adab3e', - '', ], }, { @@ -294,32 +294,33 @@ const result = new MutableDataFrame({ config: {}, values: [ 'myfrontend-devAWS::EC2::Instance', - 'DynamoDBAWS::DynamoDB::Table', 'eebec87ce4dd8225', - '3f8b028e1847bc4c', '4ab39ad12cff04b5', + 'DynamoDBAWS::DynamoDB::Table', + '3f8b028e1847bc4c', ], }, { name: 'parentSpanID', + type: FieldType.string, config: {}, values: [ - undefined, undefined, 'myfrontend-devAWS::EC2::Instance', - 'DynamoDBAWS::DynamoDB::Table', 'eebec87ce4dd8225', + undefined, + 'DynamoDBAWS::DynamoDB::Table', ], }, { name: 'operationName', config: {}, - values: ['AWS::EC2::Instance', 'AWS::DynamoDB::Table', 'myfrontend-dev', 'DynamoDB', 'DynamoDB'], + values: ['AWS::EC2::Instance', 'myfrontend-dev', 'DynamoDB', 'AWS::DynamoDB::Table', 'DynamoDB'], }, { name: 'serviceName', config: {}, - values: ['myfrontend-dev', 'DynamoDB', 'myfrontend-dev', 'DynamoDB', 'myfrontend-dev'], + values: ['myfrontend-dev', 'myfrontend-dev', 'myfrontend-dev', 'DynamoDB', 'DynamoDB'], }, { name: 'serviceTags', @@ -334,7 +335,7 @@ const result = new MutableDataFrame({ [ { key: 'name', - value: 'DynamoDB', + value: 'myfrontend-dev', }, ], [ @@ -352,7 +353,7 @@ const result = new MutableDataFrame({ [ { key: 'name', - value: 'myfrontend-dev', + value: 'DynamoDB', }, ], ], @@ -365,7 +366,7 @@ const result = new MutableDataFrame({ { name: 'duration', config: {}, - values: [0, 0, 48, 47, 47], + values: [0, 48, 47, 0, 47], }, { name: 'logs', @@ -376,7 +377,6 @@ const result = new MutableDataFrame({ name: 'tags', config: {}, values: [ - undefined, undefined, [ { @@ -394,43 +394,44 @@ const result = new MutableDataFrame({ ], [ { - key: 'metadata.http.dns.addresses[0].IP', - value: '4.2.123.160', + key: 'aws.region', + value: 'us-east-2', }, { - key: 'metadata.http.dns.addresses[1].IP', - value: '22.23.14.122', + key: 'aws.resource_names[0]', + value: 'awseb-e-cmpzepijzr-stack-StartupSignupsTable-SGJF3KIBUQNA', }, { - key: 'in progress', - value: false, + key: 'http.response.status', + value: 400, }, { - key: 'origin', - value: 'AWS::DynamoDB::Table', + key: 'in progress', + value: false, }, { key: 'error', value: true, }, ], + undefined, [ { - key: 'aws.region', - value: 'us-east-2', - }, - { - key: 'aws.resource_names[0]', - value: 'awseb-e-cmpzepijzr-stack-StartupSignupsTable-SGJF3KIBUQNA', + key: 'metadata.http.dns.addresses[0].IP', + value: '4.2.123.160', }, { - key: 'http.response.status', - value: 400, + key: 'metadata.http.dns.addresses[1].IP', + value: '22.23.14.122', }, { key: 'in progress', value: false, }, + { + key: 'origin', + value: 'AWS::DynamoDB::Table', + }, { key: 'error', value: true, @@ -447,20 +448,35 @@ const result = new MutableDataFrame({ name: 'stackTraces', config: {}, values: [ - undefined, - undefined, undefined, undefined, [ 'ConditionalCheckFailedException: The conditional request failed\nat features.constructor.captureAWSRequest [as customRequestHandler] (/var/app/current/node_modules/aws-xray-sdk/lib/patchers/aws_p.js:66)\nat features.constructor.addAllRequestListeners (/var/app/current/node_modules/aws-sdk/lib/service.js:266)', 'UndefinedStackException: Undefined stack exception', ], + undefined, + undefined, ], }, { name: 'errorIconColor', + type: FieldType.string, config: {}, - values: [undefined, undefined, undefined, undefined, '#FFC46E'], + values: [undefined, undefined, '#FFC46E', undefined, undefined], + }, + { + config: {}, + labels: undefined, + name: '__log_group', + type: FieldType.string, + values: ['', '', '', '', ''], + }, + { + config: {}, + labels: undefined, + name: '__request_id', + type: FieldType.string, + values: [undefined, undefined, undefined, undefined, undefined], }, ], meta: { @@ -468,6 +484,69 @@ const result = new MutableDataFrame({ }, }); +describe('transformResponse function', () => { + it('should transform aws x-ray response to correct format', () => { + expect(transformTraceResponse(awsResponse as any)).toEqual(result); + }); + + it("should handle response that is in progress (doesn't have an end time)", () => { + const aws = { + Id: '1-5efdaeaa-f2a07d044bad19595ac13935', + Segments: [ + { + Document: { + id: '5c6cc52b0685e278', + name: 'myfrontend-dev', + origin: 'AWS::EC2::Instance', + subsegments: [ + { + id: 'e5ea9d95ecda4d8a', + name: 'response', + start_time: 1595878288.1899369, + in_progress: true, + }, + ], + }, + }, + ], + }; + + const view = new DataFrameView(transformTraceResponse(aws as any)); + expect(view.get(1).duration).toBe(0); + }); + + it('should handle response without full url', () => { + const aws = { + Id: '1-5efdaeaa-f2a07d044bad19595ac13935', + Segments: [ + { + Document: { + id: '5c6cc52b0685e278', + name: 'myfrontend-dev', + origin: 'AWS::EC2::Instance', + http: { + request: { + url: '/path/something', + }, + }, + subsegments: [ + { + id: 'e5ea9d95ecda4d8a', + name: 'response', + start_time: 1595878288.1899369, + in_progress: true, + }, + ], + }, + }, + ], + }; + + const view = new DataFrameView(transformTraceResponse(aws as any)); + expect(view.get(1).duration).toBe(0); + }); +}); + const resultWithSql = new MutableDataFrame({ fields: [ { @@ -1022,61 +1101,4 @@ describe('transformTraceResponse function', () => { it('should transform an aws x-ray response with sql to jaeger span', () => { expect(transformTraceResponse(awsResponseWithSql)).toEqual(resultWithSql); }); - - it("should handle response that is in progress (doesn't have an end time)", () => { - const aws = { - Id: '1-5efdaeaa-f2a07d044bad19595ac13935', - Segments: [ - { - Document: { - id: '5c6cc52b0685e278', - name: 'myfrontend-dev', - origin: 'AWS::EC2::Instance', - subsegments: [ - { - id: 'e5ea9d95ecda4d8a', - name: 'response', - start_time: 1595878288.1899369, - in_progress: true, - }, - ], - }, - }, - ], - }; - - const view = new DataFrameView(transformTraceResponse(aws as any)); - expect(view.get(1).duration).toBe(0); - }); - - it('should handle response without full url', () => { - const aws = { - Id: '1-5efdaeaa-f2a07d044bad19595ac13935', - Segments: [ - { - Document: { - id: '5c6cc52b0685e278', - name: 'myfrontend-dev', - origin: 'AWS::EC2::Instance', - http: { - request: { - url: '/path/something', - }, - }, - subsegments: [ - { - id: 'e5ea9d95ecda4d8a', - name: 'response', - start_time: 1595878288.1899369, - in_progress: true, - }, - ], - }, - }, - ], - }; - - const view = new DataFrameView(transformTraceResponse(aws as any)); - expect(view.get(1).duration).toBe(0); - }); }); diff --git a/src/utils/transform.ts b/src/utils/transform.ts index c8a9a6c..88ca30b 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -23,41 +23,54 @@ import { flatten } from './flatten'; const MS_MULTIPLIER = 1000; +type XrayTraceSpanRow = TraceSpanRow & { + __log_group: string; +}; + /** * Transforms response to format used by Grafana. */ export function transformTraceResponse(data: XrayTraceData): DataFrame { - const subSegmentSpans: TraceSpanRow[] = []; - // parentSpans are artificial spans used to group services that has the same name to mimic how the traces look - // in X-ray console. - const parentSpans: TraceSpanRow[] = []; + const parentSpans: Record = {}; + const spans: XrayTraceSpanRow[] = []; + let requestId: string | undefined = undefined; + + for (const segment of data.Segments) { + if (segment.Document.aws?.request_id) { + requestId = segment.Document.aws?.request_id; + } - const segmentSpans = data.Segments.map((segment) => { const [serviceName, serviceTags] = getProcess(segment); - getSubSegments(segment.Document, (subSegment, segmentParent) => { - subSegmentSpans.push(transformSegmentDocument(subSegment, serviceName, serviceTags, segmentParent.id)); - }); - let parentSpan = parentSpans.find((ps) => ps.spanID === segment.Document.name + segment.Document.origin); + const parentSpanId = segment.Document.name + segment.Document.origin; + const span = transformSegmentDocument(segment.Document, serviceName, serviceTags, parentSpanId); - if (!parentSpan) { - parentSpan = { + if (!parentSpans[parentSpanId]) { + // parent span is artificial span used to group services that has the same name to mimic how the traces look + // in X-ray console. + const parentSpan = { // TODO: maybe the duration should be the min(startTime) - max(endTime) of all child spans duration: 0, logs: [], operationName: segment.Document.origin ?? segment.Document.name, serviceName, serviceTags, - spanID: segment.Document.name + segment.Document.origin, + spanID: parentSpanId, startTime: segment.Document.start_time * MS_MULTIPLIER, traceID: segment.Document.trace_id || '', parentSpanID: undefined, + __log_group: span.__log_group, }; - parentSpans.push(parentSpan); + spans.push(parentSpan); + parentSpans[parentSpanId] = parentSpan; } - return transformSegmentDocument(segment.Document, serviceName, serviceTags, parentSpan.spanID); - }); + spans.push(span); + + getSubSegments(segment.Document, (subSegment, segmentParent) => { + spans.push(transformSegmentDocument(subSegment, serviceName, serviceTags, segmentParent.id)); + }); + } const frame = new MutableDataFrame({ fields: [ @@ -74,14 +87,16 @@ export function transformTraceResponse(data: XrayTraceData): DataFrame { { name: 'warnings', type: FieldType.other }, { name: 'stackTraces', type: FieldType.other }, { name: 'errorIconColor', type: FieldType.string }, + { name: '__log_group', type: FieldType.string }, + { name: '__request_id', type: FieldType.string }, ], meta: { preferredVisualisationType: 'trace', }, }); - for (const span of [...parentSpans, ...segmentSpans, ...subSegmentSpans]) { - frame.add(span); + for (const span of spans) { + frame.add({ ...span, __request_id: requestId }); } return frame; @@ -104,7 +119,7 @@ function transformSegmentDocument( serviceName: string, serviceTags: TraceKeyValuePair[], parentId?: string -): TraceSpanRow { +): XrayTraceSpanRow { const duration = document.end_time ? document.end_time * MS_MULTIPLIER - document.start_time * MS_MULTIPLIER : 0; return { traceID: document.trace_id || '', @@ -119,6 +134,7 @@ function transformSegmentDocument( stackTraces: getStackTrace(document), tags: getTagsForSpan(document), errorIconColor: getIconColor(document), + __log_group: getLogGroup(document), }; } @@ -224,6 +240,18 @@ function getProcess(segment: XrayTraceDataSegment): [string, TraceKeyValuePair[] return [segment.Document.name, tags]; } +function getLogGroup(document: XrayTraceDataSegmentDocument) { + if (document.origin?.includes('AWS::Lambda')) { + return '/aws/lambda/' + document.name; + } + + if (document.origin?.includes('AWS::ApiGateway')) { + return `API-Gateway-Execution-Logs_${document.aws?.api_gateway.rest_api_id}/${document.aws?.api_gateway.stage}`; + } + + return ''; +} + function valueToTag(key: string, value: string | number | undefined): TraceKeyValuePair | undefined { if (!value || (Array.isArray(value) && !value.length)) { return undefined;