Tempo Service Map: Optionally group nodes also by service.namespace (#70803)

* tempo service map: optionally handle namespace

* undo escape attempt

* regen cue
This commit is contained in:
Domas
2023-07-18 13:11:12 +03:00
committed by GitHub
parent 45fec1ae26
commit 1347d85b50
8 changed files with 138 additions and 54 deletions
@@ -35,6 +35,10 @@ export interface TempoQuery extends common.DataQuery {
* Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
*/
search?: string;
/**
* Use service.namespace in addition to service.name to uniquely identify a service.
*/
serviceMapIncludeNamespace?: boolean;
/**
* Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}
*/
@@ -110,6 +110,9 @@ type TempoQuery struct {
// Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
Search *string `json:"search,omitempty"`
// Use service.namespace in addition to service.name to uniquely identify a service.
ServiceMapIncludeNamespace *bool `json:"serviceMapIncludeNamespace,omitempty"`
// Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}
ServiceMapQuery *string `json:"serviceMapQuery,omitempty"`
@@ -44,6 +44,8 @@ composableKinds: DataQuery: {
maxDuration?: string
// Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}
serviceMapQuery?: string
// Use service.namespace in addition to service.name to uniquely identify a service.
serviceMapIncludeNamespace?: bool
// Defines the maximum number of traces that are returned from Tempo
limit?: int64
// Use the streaming API to get partial results as they are available
@@ -32,6 +32,10 @@ export interface TempoQuery extends common.DataQuery {
* Logfmt query to filter traces by their tags. Example: http.status_code=200 error=true
*/
search?: string;
/**
* Use service.namespace in addition to service.name to uniquely identify a service.
*/
serviceMapIncludeNamespace?: boolean;
/**
* Filters to be included in a PromQL query to select data for the service graph. Example: {client="app",service="app"}
*/
@@ -788,12 +788,14 @@ function makePromServiceMapRequest(options: DataQueryRequest<TempoQuery>): DataQ
return {
...options,
targets: serviceMapMetrics.map((metric) => {
const { serviceMapQuery, serviceMapIncludeNamespace: serviceMapIncludeNamespace } = options.targets[0];
const extraSumByFields = serviceMapIncludeNamespace ? ', client_service_namespace, server_service_namespace' : '';
return {
format: 'table',
refId: metric,
// options.targets[0] is not correct here, but not sure what should happen if you have multiple queries for
// service map at the same time anyway
expr: `sum by (client, server) (rate(${metric}${options.targets[0].serviceMapQuery || ''}[$__range]))`,
expr: `sum by (client, server${extraSumByFields}) (rate(${metric}${serviceMapQuery || ''}[$__range]))`,
instant: true,
};
}),
@@ -72,6 +72,7 @@ it('assigns correct field type even if values are numbers', async () => {
expect(nodes.fields).toMatchObject([
{ name: 'id', values: ['0', '1'], type: FieldType.string },
{ name: 'title', values: ['0', '1'], type: FieldType.string },
{ name: 'subtitle', type: FieldType.string, values: [] },
{ name: 'mainstat', values: [NaN, NaN], type: FieldType.number },
{ name: 'secondarystat', values: [10, 20], type: FieldType.number },
{ name: 'arc__success', values: [1, 1], type: FieldType.number },
@@ -86,7 +87,7 @@ describe('mapPromMetricsToServiceMap', () => {
to: dateTime('2000-01-01T00:01:00'),
};
const { nodes, edges } = mapPromMetricsToServiceMap(
[{ data: [totalsPromMetric, secondsPromMetric, failedPromMetric] }],
[{ data: [totalsPromMetric(), secondsPromMetric(), failedPromMetric()] }],
{
...range,
raw: range,
@@ -96,6 +97,7 @@ describe('mapPromMetricsToServiceMap', () => {
expect(nodes.fields).toMatchObject([
{ name: 'id', values: ['db', 'app', 'lb'] },
{ name: 'title', values: ['db', 'app', 'lb'] },
{ name: 'subtitle', values: [] },
{ name: 'mainstat', values: [1000, 2000, NaN] },
{ name: 'secondarystat', values: [10, 20, NaN] },
{ name: 'arc__success', values: [0.8, 0.25, 1] },
@@ -110,6 +112,37 @@ describe('mapPromMetricsToServiceMap', () => {
]);
});
it('transforms prom metrics to service graph inlucding namespace', async () => {
const range = {
from: dateTime('2000-01-01T00:00:00'),
to: dateTime('2000-01-01T00:01:00'),
};
const { nodes, edges } = mapPromMetricsToServiceMap(
[{ data: [totalsPromMetric(true), secondsPromMetric(true), failedPromMetric(true)] }],
{
...range,
raw: range,
}
);
expect(nodes.fields).toMatchObject([
{ name: 'id', values: ['ns3/db', 'ns1/app', 'ns2/lb'] },
{ name: 'title', values: ['db', 'app', 'lb'] },
{ name: 'subtitle', values: ['ns3', 'ns1', 'ns2'] },
{ name: 'mainstat', values: [1000, 2000, NaN] },
{ name: 'secondarystat', values: [10, 20, NaN] },
{ name: 'arc__success', values: [0.8, 0.25, 1] },
{ name: 'arc__failed', values: [0.2, 0.75, 0] },
]);
expect(edges.fields).toMatchObject([
{ name: 'id', values: ['ns1/app_ns3/db', 'ns2/lb_ns1/app'] },
{ name: 'source', values: ['ns1/app', 'ns2/lb'] },
{ name: 'target', values: ['ns3/db', 'ns1/app'] },
{ name: 'mainstat', values: [1000, 2000] },
{ name: 'secondarystat', values: [10, 20] },
]);
});
it('handles invalid failed count', () => {
// If node.failed > node.total, the stat circle will render in the wrong position
// Fixed this by limiting the failed value to the total value
@@ -118,7 +151,7 @@ describe('mapPromMetricsToServiceMap', () => {
to: dateTime('2000-01-01T00:01:00'),
};
const { nodes } = mapPromMetricsToServiceMap(
[{ data: [totalsPromMetric, secondsPromMetric, invalidFailedPromMetric] }],
[{ data: [totalsPromMetric(), secondsPromMetric(), invalidFailedPromMetric] }],
{
...range,
raw: range,
@@ -128,6 +161,7 @@ describe('mapPromMetricsToServiceMap', () => {
expect(nodes.fields).toMatchObject([
{ name: 'id', values: ['db', 'app', 'lb'] },
{ name: 'title', values: ['db', 'app', 'lb'] },
{ name: 'subtitle', values: [] },
{ name: 'mainstat', values: [1000, 2000, NaN] },
{ name: 'secondarystat', values: [10, 20, NaN] },
{ name: 'arc__success', values: [0, 0, 1] },
@@ -160,44 +194,65 @@ const missingSpanResponse = createDataFrame({
],
});
const totalsPromMetric = createDataFrame({
refId: 'traces_service_graph_request_total',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #traces_service_graph_request_total', values: [10, 20] },
],
});
const totalsPromMetric = (namespace?: boolean) =>
createDataFrame({
refId: 'traces_service_graph_request_total',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #traces_service_graph_request_total', values: [10, 20] },
...(namespace
? [
{ name: 'client_service_namespace', values: ['ns1', 'ns2'] },
{ name: 'server_service_namespace', values: ['ns3', 'ns1'] },
]
: []),
],
});
const secondsPromMetric = createDataFrame({
refId: 'traces_service_graph_request_server_seconds_sum',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #traces_service_graph_request_server_seconds_sum', values: [10, 40] },
],
});
const secondsPromMetric = (namespace?: boolean) =>
createDataFrame({
refId: 'traces_service_graph_request_server_seconds_sum',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #traces_service_graph_request_server_seconds_sum', values: [10, 40] },
...(namespace
? [
{ name: 'client_service_namespace', values: ['ns1', 'ns2'] },
{ name: 'server_service_namespace', values: ['ns3', 'ns1'] },
]
: []),
],
});
const failedPromMetric = createDataFrame({
refId: 'traces_service_graph_request_failed_total',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #traces_service_graph_request_failed_total', values: [2, 15] },
],
});
const failedPromMetric = (namespace?: boolean) =>
createDataFrame({
refId: 'traces_service_graph_request_failed_total',
fields: [
{ name: 'Time', values: [1628169788000, 1628169788000] },
{ name: 'client', values: ['app', 'lb'] },
{ name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] },
{ name: 'job', values: ['local_scrape', 'local_scrape'] },
{ name: 'server', values: ['db', 'app'] },
{ name: 'tempo_config', values: ['default', 'default'] },
{ name: 'Value #traces_service_graph_request_failed_total', values: [2, 15] },
...(namespace
? [
{ name: 'client_service_namespace', values: ['ns1', 'ns2'] },
{ name: 'server_service_namespace', values: ['ns3', 'ns1'] },
]
: []),
],
});
const invalidFailedPromMetric = createDataFrame({
refId: 'traces_service_graph_request_failed_total',
@@ -173,7 +173,7 @@ export function mapPromMetricsToServiceMap(
const frames = getMetricFrames(responses);
// First just collect data from the metrics into a map with nodes and edges as keys
const nodesMap: Record<string, ServiceMapStatistics> = {};
const nodesMap: Record<string, NodeObject> = {};
const edgesMap: Record<string, EdgeObject> = {};
// At this moment we don't have any error/success or other counts so we just use these 2
collectMetricData(frames[totalsMetric], 'total', totalsMetric, nodesMap, edgesMap);
@@ -191,6 +191,7 @@ function createServiceMapDataFrames() {
const nodes = createDF('Nodes', [
{ name: Fields.id, type: FieldType.string },
{ name: Fields.title, type: FieldType.string, config: { displayName: 'Service name' } },
{ name: Fields.subTitle, type: FieldType.string, config: { displayName: 'Service namespace' } },
{ name: Fields.mainStat, type: FieldType.number, config: { unit: 'ms/r', displayName: 'Average response time' } },
{
name: Fields.secondaryStat,
@@ -241,6 +242,11 @@ type ServiceMapStatistics = {
failed?: number;
};
type NodeObject = ServiceMapStatistics & {
name: string;
namespace?: string;
};
type EdgeObject = ServiceMapStatistics & {
source: string;
target: string;
@@ -261,7 +267,7 @@ function collectMetricData(
frame: DataFrameView | undefined,
stat: keyof ServiceMapStatistics,
metric: string,
nodesMap: Record<string, ServiceMapStatistics>,
nodesMap: Record<string, NodeObject>,
edgesMap: Record<string, EdgeObject>
) {
if (!frame) {
@@ -274,13 +280,16 @@ function collectMetricData(
for (let i = 0; i < frame.length; i++) {
const row = frame.get(i);
const edgeId = `${row.client}_${row.server}`;
const serverId = row.server_service_namespace ? `${row.server_service_namespace}/${row.server}` : row.server;
const clientId = row.client_service_namespace ? `${row.client_service_namespace}/${row.client}` : row.client;
const edgeId = `${clientId}_${serverId}`;
if (!edgesMap[edgeId]) {
// Create edge as it does not exist yet
edgesMap[edgeId] = {
target: row.server,
source: row.client,
target: serverId,
source: clientId,
[stat]: row[valueName],
};
} else {
@@ -290,20 +299,24 @@ function collectMetricData(
edgesMap[edgeId][stat] = (edgesMap[edgeId][stat] || 0) + row[valueName];
}
if (!nodesMap[row.server]) {
if (!nodesMap[serverId]) {
// Create node for server
nodesMap[row.server] = {
nodesMap[serverId] = {
name: row.server,
namespace: row.server_service_namespace,
[stat]: row[valueName],
};
} else {
// Add stat to server node. Sum up values if there are multiple edges targeting this server node.
nodesMap[row.server][stat] = (nodesMap[row.server][stat] || 0) + row[valueName];
nodesMap[serverId][stat] = (nodesMap[serverId][stat] || 0) + row[valueName];
}
if (!nodesMap[row.client]) {
if (!nodesMap[clientId]) {
// Create the client node but don't add the stat as edge stats are attributed to the server node. This means for
// example that the number of requests in a node show how many requests it handled not how many it generated.
nodesMap[row.client] = {
nodesMap[clientId] = {
name: row.client,
namespace: row.client_service_namespace,
[stat]: 0,
};
}
@@ -311,7 +324,7 @@ function collectMetricData(
}
function convertToDataFrames(
nodesMap: Record<string, ServiceMapStatistics>,
nodesMap: Record<string, NodeObject>,
edgesMap: Record<string, EdgeObject>,
range: TimeRange
): { nodes: DataFrame; edges: DataFrame } {
@@ -320,7 +333,8 @@ function convertToDataFrames(
const node = nodesMap[nodeId];
nodes.add({
[Fields.id]: nodeId,
[Fields.title]: nodeId,
[Fields.title]: node.name,
[Fields.subTitle]: node.namespace,
// NaN will not be shown in the node graph. This happens for a root client node which did not process
// any requests itself.
[Fields.mainStat]: node.total ? (node.seconds! / node.total) * 1000 : Number.NaN, // Average response time
+3 -3
View File
@@ -41,7 +41,7 @@ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
overflow: hidden;
white-space: nowrap;
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String()};
width: 100px;
width: 140px;
`,
statsText: css`
@@ -90,9 +90,9 @@ export const Node = memo(function Node(props: {
<g className={styles.text} style={{ pointerEvents: 'none' }}>
<NodeContents node={node} hovering={hovering} />
<foreignObject
x={node.x - (isHovered ? 100 : 50)}
x={node.x - (isHovered ? 100 : 70)}
y={node.y + nodeR + 5}
width={isHovered ? '200' : '100'}
width={isHovered ? '200' : '140'}
height="40"
>
<div className={cx(styles.titleText, isHovered && styles.textHovering)}>