From b3845bcfb3140333ac9cc568f5426f329d9c184d Mon Sep 17 00:00:00 2001 From: mandryllo Date: Tue, 14 Apr 2026 14:51:55 +0200 Subject: [PATCH 1/4] feat: create logs & traces dash --- src/components/grafana/builder.ts | 10 ++++ src/components/grafana/dashboards/index.ts | 1 + .../grafana/dashboards/logs-and-traces.ts | 37 +++++++++++++++ src/components/grafana/panels/helpers.ts | 47 +++++++++++++++++++ src/components/grafana/panels/logs.ts | 15 ++++++ src/components/grafana/panels/traces.ts | 13 +++++ src/components/grafana/panels/types.ts | 8 +++- 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/components/grafana/dashboards/logs-and-traces.ts create mode 100644 src/components/grafana/panels/logs.ts create mode 100644 src/components/grafana/panels/traces.ts diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index 2e46f068..64c5fd97 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -7,6 +7,10 @@ import { } from './connections'; import { Grafana } from './grafana'; import type { GrafanaDashboardBuilder } from './dashboards/builder'; +import { + createLogsAndTracesDashboard, + LogsAndTracesDashboard, +} from './dashboards/logs-and-traces'; import { createSloDashboard, SloDashboard } from './dashboards/slo'; export class GrafanaBuilder { @@ -92,6 +96,12 @@ export class GrafanaBuilder { return this; } + public addLogsAndTracesDashboard(config: LogsAndTracesDashboard.Args): this { + this.dashboardBuilders.push(createLogsAndTracesDashboard(config)); + + return this; + } + public addDashboard( dashboard: GrafanaDashboardBuilder.CreateDashboard, ): this { diff --git a/src/components/grafana/dashboards/index.ts b/src/components/grafana/dashboards/index.ts index d4240168..8d5104e1 100644 --- a/src/components/grafana/dashboards/index.ts +++ b/src/components/grafana/dashboards/index.ts @@ -1,2 +1,3 @@ export { GrafanaDashboardBuilder as DashboardBuilder } from './builder'; +export { createLogsAndTracesDashboard } from './logs-and-traces'; export { createSloDashboard } from './slo'; diff --git a/src/components/grafana/dashboards/logs-and-traces.ts b/src/components/grafana/dashboards/logs-and-traces.ts new file mode 100644 index 00000000..ff6039d3 --- /dev/null +++ b/src/components/grafana/dashboards/logs-and-traces.ts @@ -0,0 +1,37 @@ +import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; +import { GrafanaDashboardBuilder } from './builder'; +import { createLogsListPanel } from '../panels/logs'; +import { createTracesListPanel } from '../panels/traces'; + +export namespace LogsAndTracesDashboard { + export type Args = { + name: string; + title: string; + logsDataSourceName: string; + logGroupName: string; + tracesDataSourceName: string; + dashboardConfig?: GrafanaDashboardBuilder.Config; + }; +} + +const defaults = { + title: 'Logs & Traces', + dashboardConfig: {}, +}; + +export function createLogsAndTracesDashboard( + config: LogsAndTracesDashboard.Args, +): GrafanaDashboardBuilder.CreateDashboard { + const argsWithDefaults = mergeWithDefaults(defaults, config); + const { title, logsDataSourceName, logGroupName, tracesDataSourceName } = + argsWithDefaults; + + return new GrafanaDashboardBuilder(config.name) + .withConfig(argsWithDefaults.dashboardConfig) + .withTitle(title) + .addPanel( + createLogsListPanel({ logGroupName, dataSourceName: logsDataSourceName }), + ) + .addPanel(createTracesListPanel({ dataSourceName: tracesDataSourceName })) + .build(); +} diff --git a/src/components/grafana/panels/helpers.ts b/src/components/grafana/panels/helpers.ts index 97a26696..5e2af116 100644 --- a/src/components/grafana/panels/helpers.ts +++ b/src/components/grafana/panels/helpers.ts @@ -136,3 +136,50 @@ export function createBurnRatePanel( }, }; } + +export function createLogsPanel( + title: string, + position: Panel.Position, + dataSource: string, + logGroupName: string, + expression: string, +): Panel { + return { + type: 'logs', + title, + gridPos: position, + datasource: dataSource, + targets: [ + { + expression, + logGroups: [{ name: logGroupName }], + queryMode: 'Logs', + }, + ], + fieldConfig: { + defaults: {}, + }, + }; +} + +export function createTablePanel( + title: string, + position: Panel.Position, + dataSource: string, + queryType: string, +): Panel { + return { + type: 'table', + title, + gridPos: position, + datasource: dataSource, + targets: [ + { + queryType, + }, + ], + fieldConfig: { + defaults: {}, + }, + }; +} diff --git a/src/components/grafana/panels/logs.ts b/src/components/grafana/panels/logs.ts new file mode 100644 index 00000000..aa0f402a --- /dev/null +++ b/src/components/grafana/panels/logs.ts @@ -0,0 +1,15 @@ +import { Panel } from './types'; +import { createLogsPanel } from './helpers'; + +export function createLogsListPanel(config: { + logGroupName: string; + dataSourceName: string; +}): Panel { + return createLogsPanel( + 'Logs', + { x: 0, y: 0, w: 24, h: 10 }, + config.dataSourceName, + config.logGroupName, + 'fields @timestamp, @message, @logStream\n| sort @timestamp desc\n| limit 20', + ); +} diff --git a/src/components/grafana/panels/traces.ts b/src/components/grafana/panels/traces.ts new file mode 100644 index 00000000..7e6223f9 --- /dev/null +++ b/src/components/grafana/panels/traces.ts @@ -0,0 +1,13 @@ +import { Panel } from './types'; +import { createTablePanel } from './helpers'; + +export function createTracesListPanel(config: { + dataSourceName: string; +}): Panel { + return createTablePanel( + 'Traces', + { x: 0, y: 0, w: 24, h: 10 }, + config.dataSourceName, + 'getTraceSummaries', + ); +} diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts index 889b8785..1f0d3736 100644 --- a/src/components/grafana/panels/types.ts +++ b/src/components/grafana/panels/types.ts @@ -4,8 +4,12 @@ export type Panel = { type: string; datasource: string; targets: { - expr: string; - legendFormat: string; + expr?: string; + expression?: string; + legendFormat?: string; + logGroups?: { name: string }[]; + queryMode?: string; + queryType?: string; }[]; fieldConfig: { defaults: { From 7cde18a1ddf049b292ec8ce70be3a13df2bfca63 Mon Sep 17 00:00:00 2001 From: mandryllo Date: Thu, 16 Apr 2026 20:26:19 +0200 Subject: [PATCH 2/4] refactor: add variables --- src/components/grafana/dashboards/builder.ts | 13 +- .../grafana/dashboards/logs-and-traces.ts | 13 +- src/components/grafana/panels/helpers.ts | 29 +- src/components/grafana/panels/logs.ts | 39 ++- src/components/grafana/panels/types.ts | 12 +- src/components/grafana/variables/helpers.ts | 18 + src/components/grafana/variables/index.ts | 4 + src/components/grafana/variables/log-level.ts | 13 + .../grafana/variables/status-code.ts | 13 + src/components/grafana/variables/types.ts | 15 + tests/grafana/logs-and-traces-grafana.test.ts | 330 ++++++++++++++++++ 11 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 src/components/grafana/variables/helpers.ts create mode 100644 src/components/grafana/variables/index.ts create mode 100644 src/components/grafana/variables/log-level.ts create mode 100644 src/components/grafana/variables/status-code.ts create mode 100644 src/components/grafana/variables/types.ts create mode 100644 tests/grafana/logs-and-traces-grafana.test.ts diff --git a/src/components/grafana/dashboards/builder.ts b/src/components/grafana/dashboards/builder.ts index 483fc0ba..04a944b0 100644 --- a/src/components/grafana/dashboards/builder.ts +++ b/src/components/grafana/dashboards/builder.ts @@ -1,6 +1,7 @@ import * as pulumi from '@pulumi/pulumi'; import * as grafana from '@pulumiverse/grafana'; import { Panel } from '../panels/types'; +import { Variable } from '../variables/types'; import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; export namespace GrafanaDashboardBuilder { @@ -25,6 +26,7 @@ export class GrafanaDashboardBuilder { private readonly panels: Panel[] = []; private configuration: GrafanaDashboardBuilder.Config = {}; private title?: string; + private variables: Variable[] = []; constructor(name: string) { this.name = name; @@ -42,6 +44,12 @@ export class GrafanaDashboardBuilder { return this; } + withVariable(variable: Variable) { + this.variables.push(variable); + + return this; + } + addPanel(panel: Panel): this { this.panels.push(panel); @@ -61,7 +69,7 @@ export class GrafanaDashboardBuilder { ); } - const { name, title, panels } = this; + const { name, title, panels, variables } = this; const options = mergeWithDefaults(defaults, this.configuration); return (folder, opts) => { @@ -74,6 +82,9 @@ export class GrafanaDashboardBuilder { timezone: options.timezone, refresh: options.refresh, panels, + templating: { + list: variables, + }, }), }, opts, diff --git a/src/components/grafana/dashboards/logs-and-traces.ts b/src/components/grafana/dashboards/logs-and-traces.ts index ff6039d3..ec73937a 100644 --- a/src/components/grafana/dashboards/logs-and-traces.ts +++ b/src/components/grafana/dashboards/logs-and-traces.ts @@ -1,7 +1,8 @@ import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; import { GrafanaDashboardBuilder } from './builder'; -import { createLogsListPanel } from '../panels/logs'; -import { createTracesListPanel } from '../panels/traces'; +import { createStatusCodeVariable } from '../variables/status-code'; +import { createLogLevelVariable } from '../variables/log-level'; +import { createLogsListWithFiltersPanel } from '../panels/logs'; export namespace LogsAndTracesDashboard { export type Args = { @@ -29,9 +30,13 @@ export function createLogsAndTracesDashboard( return new GrafanaDashboardBuilder(config.name) .withConfig(argsWithDefaults.dashboardConfig) .withTitle(title) + .withVariable(createStatusCodeVariable()) + .withVariable(createLogLevelVariable()) .addPanel( - createLogsListPanel({ logGroupName, dataSourceName: logsDataSourceName }), + createLogsListWithFiltersPanel({ + logGroupName, + dataSourceName: logsDataSourceName, + }), ) - .addPanel(createTracesListPanel({ dataSourceName: tracesDataSourceName })) .build(); } diff --git a/src/components/grafana/panels/helpers.ts b/src/components/grafana/panels/helpers.ts index 5e2af116..63f3375b 100644 --- a/src/components/grafana/panels/helpers.ts +++ b/src/components/grafana/panels/helpers.ts @@ -1,4 +1,4 @@ -import { Panel, Metric } from './types'; +import { Panel, Metric, Transformation } from './types'; const percentageFieldConfig = { unit: 'percent', @@ -183,3 +183,30 @@ export function createTablePanel( }, }; } + +export function createTableForLogsPanel( + title: string, + position: Panel.Position, + dataSource: string, + logGroupName: string, + expression: string, + transformation: Transformation, +): Panel { + return { + type: 'table', + title, + gridPos: position, + datasource: dataSource, + targets: [ + { + expression, + logGroups: [{ name: logGroupName }], + queryMode: 'Logs', + }, + ], + transformations: [transformation], + fieldConfig: { + defaults: {}, + }, + }; +} diff --git a/src/components/grafana/panels/logs.ts b/src/components/grafana/panels/logs.ts index aa0f402a..fd6b87a9 100644 --- a/src/components/grafana/panels/logs.ts +++ b/src/components/grafana/panels/logs.ts @@ -1,5 +1,9 @@ import { Panel } from './types'; -import { createLogsPanel } from './helpers'; +import { + createLogsPanel, + createTablePanel, + createTableForLogsPanel, +} from './helpers'; export function createLogsListPanel(config: { logGroupName: string; @@ -13,3 +17,36 @@ export function createLogsListPanel(config: { 'fields @timestamp, @message, @logStream\n| sort @timestamp desc\n| limit 20', ); } + +export function createLogsListWithFiltersPanel(config: { + logGroupName: string; + dataSourceName: string; +}): Panel { + return createTableForLogsPanel( + 'Logs', + { x: 0, y: 0, w: 24, h: 12 }, + config.dataSourceName, + config.logGroupName, + `fields @timestamp\n| parse @message '"body":"*"' as body\n| parse @message '"res":{"statusCode":*}' as statusCode\n| parse @message '"severity_text":"*"' as logLevel\n| filter \${status_code}\n| filter \${log_level}\n| sort @timestamp desc\n| limit 20`, + { + id: 'organize', + options: { + renameByName: { + statusCode: 'Status Code', + logLevel: 'Log Level', + body: 'Body', + '@timestamp': 'Timestamp', + }, + indexByName: { + '@timestamp': 0, + statusCode: 1, + logLevel: 2, + body: 3, + }, + excludeByName: { + Value: true, + }, + }, + }, + ); +} diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts index 1f0d3736..4bb87f7e 100644 --- a/src/components/grafana/panels/types.ts +++ b/src/components/grafana/panels/types.ts @@ -11,7 +11,7 @@ export type Panel = { queryMode?: string; queryType?: string; }[]; - fieldConfig: { + fieldConfig?: { defaults: { unit?: string; min?: number; @@ -29,6 +29,7 @@ export type Panel = { }; }; }; + transformations?: Transformation[]; options?: { colorMode?: string; graphMode?: string; @@ -61,3 +62,12 @@ export type Threshold = { value: number | null; color: string; }; + +export type Transformation = { + id: string; + options: { + renameByName?: Record; + excludeByName?: Record; + indexByName?: Record; + }; +}; diff --git a/src/components/grafana/variables/helpers.ts b/src/components/grafana/variables/helpers.ts new file mode 100644 index 00000000..37bd3b8e --- /dev/null +++ b/src/components/grafana/variables/helpers.ts @@ -0,0 +1,18 @@ +import { BuildQuery, Variable, VariableOption } from './types'; + +const buildQuery: BuildQuery = options => JSON.stringify(options); + +export function createCustomVariable( + name: string, + label: string, + options: VariableOption[], +): Variable { + return { + type: 'custom', + name, + label, + query: buildQuery(options), + current: options[0], + valuesFormat: 'json', + }; +} diff --git a/src/components/grafana/variables/index.ts b/src/components/grafana/variables/index.ts new file mode 100644 index 00000000..1c8f98cd --- /dev/null +++ b/src/components/grafana/variables/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './helpers'; +export * from './status-code'; +export * from './log-level'; diff --git a/src/components/grafana/variables/log-level.ts b/src/components/grafana/variables/log-level.ts new file mode 100644 index 00000000..f6c62fec --- /dev/null +++ b/src/components/grafana/variables/log-level.ts @@ -0,0 +1,13 @@ +import { Variable } from './types'; +import { createCustomVariable } from './helpers'; + +export function createLogLevelVariable(): Variable { + return createCustomVariable('log_level', 'Log Level', [ + { text: 'info', value: "logLevel = 'info'" }, + { text: 'trace', value: "logLevel = 'trace'" }, + { text: 'debug', value: "logLevel = 'debug'" }, + { text: 'warn', value: "logLevel = 'warn'" }, + { text: 'error', value: "logLevel = 'error'" }, + { text: 'fatal', value: "logLevel = 'fatal'" }, + ]); +} diff --git a/src/components/grafana/variables/status-code.ts b/src/components/grafana/variables/status-code.ts new file mode 100644 index 00000000..f071bb71 --- /dev/null +++ b/src/components/grafana/variables/status-code.ts @@ -0,0 +1,13 @@ +import { Variable } from './types'; +import { createCustomVariable } from './helpers'; + +export function createStatusCodeVariable(): Variable { + return createCustomVariable('status_code', 'Status Code', [ + { text: 'N/A', value: '!ispresent(statusCode)' }, + { text: '1xx', value: 'statusCode >= 100 and statusCode < 200' }, + { text: '2xx', value: 'statusCode >= 200 and statusCode < 300' }, + { text: '3xx', value: 'statusCode >= 300 and statusCode < 400' }, + { text: '4xx', value: 'statusCode >= 400 and statusCode < 500' }, + { text: '5xx', value: 'statusCode >= 500 and statusCode < 600' }, + ]); +} diff --git a/src/components/grafana/variables/types.ts b/src/components/grafana/variables/types.ts new file mode 100644 index 00000000..b289b125 --- /dev/null +++ b/src/components/grafana/variables/types.ts @@ -0,0 +1,15 @@ +export type VariableOption = { + text: string; + value: string; +}; + +export type Variable = { + type: string; + name: string; + label: string; + query: string; + current: VariableOption; + valuesFormat: string; +}; + +export type BuildQuery = (options: VariableOption[]) => string; diff --git a/tests/grafana/logs-and-traces-grafana.test.ts b/tests/grafana/logs-and-traces-grafana.test.ts new file mode 100644 index 00000000..b5ae5d61 --- /dev/null +++ b/tests/grafana/logs-and-traces-grafana.test.ts @@ -0,0 +1,330 @@ +import { it } from 'node:test'; +import * as assert from 'node:assert'; +import * as studion from '@studion/infra-code-blocks'; +import { + GetRoleCommand, + GetRolePolicyCommand, + ListRolePoliciesCommand, +} from '@aws-sdk/client-iam'; +import { Unwrap } from '@pulumi/pulumi'; +import { backOff } from '../util'; +import { GrafanaTestContext } from './test-context'; +import { grafanaRequest, requestEndpointWithExpectedStatus } from './util'; + +export function testLogsAndTracesGrafana(ctx: GrafanaTestContext) { + it('should have created the IAM role with CloudWwatch logs inline policy', async () => { + const iamRole = ctx.outputs!.logsAndTracesGrafana.connections[0].role; + const grafanaCloudWatchLogsRoleArn = iamRole.arn as unknown as Unwrap< + typeof iamRole.arn + >; + const roleName = grafanaCloudWatchLogsRoleArn.split('/').pop()!; + const { Role } = await ctx.clients.iam.send( + new GetRoleCommand({ RoleName: roleName }), + ); + assert.ok(Role, 'Grafana IAM role should exist'); + + const { PolicyNames } = await ctx.clients.iam.send( + new ListRolePoliciesCommand({ RoleName: roleName }), + ); + assert.ok( + PolicyNames && PolicyNames.length > 0, + 'IAM role should have at least one inline policy', + ); + + const { PolicyDocument } = await ctx.clients.iam.send( + new GetRolePolicyCommand({ + RoleName: roleName, + PolicyName: PolicyNames[0], + }), + ); + const policy = JSON.parse(decodeURIComponent(PolicyDocument!)) as { + Statement: Array<{ Action: string[] }>; + }; + const actions = policy.Statement.flatMap(s => s.Action).sort(); + const expectedActions = [ + 'logs:DescribeLogGroups', + 'logs:GetLogGroupFields', + 'logs:StartQuery', + 'logs:StopQuery', + 'logs:GetQueryResults', + 'logs:GetLogEvents', + ].sort(); + assert.deepStrictEqual( + actions, + expectedActions, + 'CloudWatch logs policy actions do not match expected actions', + ); + }); + + it('should have created the IAM role with xRay inline policy', async () => { + const iamRole = ctx.outputs!.logsAndTracesGrafana.connections[1].role; + const grafanaXRayRoleArn = iamRole.arn as unknown as Unwrap< + typeof iamRole.arn + >; + const roleName = grafanaXRayRoleArn.split('/').pop()!; + const { Role } = await ctx.clients.iam.send( + new GetRoleCommand({ RoleName: roleName }), + ); + assert.ok(Role, 'Grafana IAM role should exist'); + + const { PolicyNames } = await ctx.clients.iam.send( + new ListRolePoliciesCommand({ RoleName: roleName }), + ); + assert.ok( + PolicyNames && PolicyNames.length > 0, + 'IAM role should have at least one inline policy', + ); + + const { PolicyDocument } = await ctx.clients.iam.send( + new GetRolePolicyCommand({ + RoleName: roleName, + PolicyName: PolicyNames[0], + }), + ); + const policy = JSON.parse(decodeURIComponent(PolicyDocument!)) as { + Statement: Array<{ Action: string[] }>; + }; + const actions = policy.Statement.flatMap(s => s.Action).sort(); + const expectedActions = [ + 'xray:BatchGetTraces', + 'xray:GetTraceSummaries', + 'xray:GetTraceGraph', + 'xray:GetGroups', + 'xray:GetTimeSeriesServiceStatistics', + 'xray:GetInsightSummaries', + 'xray:GetInsight', + 'xray:GetServiceGraph', + 'ec2:DescribeRegions', + ].sort(); + assert.deepStrictEqual( + actions, + expectedActions, + 'XRay policy actions do not match expected actions', + ); + }); + + it('should have created the CloudWatch data source', async () => { + const grafana = ctx.outputs!.logsAndTracesGrafana; + const cloudWatchDataSource = ( + grafana.connections[0] as studion.grafana.CloudWatchLogsConnection + ).dataSource; + const cloudWatchDataSourceName = + cloudWatchDataSource.name as unknown as Unwrap< + typeof cloudWatchDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(cloudWatchDataSourceName)}`, + authToken, + ); + assert.strictEqual(statusCode, 200, 'Expected data source to exist'); + + const data = (await body.json()) as Record; + assert.strictEqual( + data.type, + 'cloudwatch', + 'Expected CloudWatch data source type', + ); + }); + }); + + it('should have created the XRay data source', async () => { + const grafana = ctx.outputs!.logsAndTracesGrafana; + const xRayDataSource = ( + grafana.connections[1] as studion.grafana.XRayConnection + ).dataSource; + const xRayDataSourceName = xRayDataSource.name as unknown as Unwrap< + typeof xRayDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(xRayDataSourceName)}`, + authToken, + ); + assert.strictEqual(statusCode, 200, 'Expected data source to exist'); + + const data = (await body.json()) as Record; + assert.strictEqual( + data.type, + 'grafana-x-ray-datasource', + 'Expected XRay data source type', + ); + }); + }); + + it('should have created the dashboard with expected panels', async () => { + const grafana = ctx.outputs!.logsAndTracesGrafana; + const dashboard = grafana.dashboards[0]; + const dashboardUid = dashboard.uid as unknown as Unwrap< + typeof dashboard.uid + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'GET', + `/api/dashboards/uid/${dashboardUid}`, + authToken, + ); + assert.strictEqual(statusCode, 200, 'Expected dashboard to exist'); + + const data = (await body.json()) as { + dashboard: { title: string; panels: Array<{ title: string }> }; + }; + assert.strictEqual( + data.dashboard.title, + 'ICB Grafana Test Logs & Traces', + 'Expected dashboard title to match', + ); + + const panelTitles = data.dashboard.panels.map(p => p.title).sort(); + const expectedPanels = ['Logs', 'Traces']; + assert.deepStrictEqual( + panelTitles, + expectedPanels.sort(), + 'Dashboard panels do not match expected panels', + ); + }); + }); + + it('should display logs data in the dashboard', async () => { + await requestEndpointWithExpectedStatus(ctx, ctx.config.usersPath, 200); + + const grafana = ctx.outputs!.logsAndTracesGrafana; + + const cloudWatchLogsDataSource = ( + grafana.connections[0] as studion.grafana.CloudWatchLogsConnection + ).dataSource; + const cloudWatchLogsDataSourceName = + cloudWatchLogsDataSource.name as unknown as Unwrap< + typeof cloudWatchLogsDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + const { body: dsBody } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(cloudWatchLogsDataSourceName)}`, + authToken, + ); + const dsData = (await dsBody.json()) as Record; + const dataSourceUid = dsData.uid as string; + const cloudWatchLogGroupName = ctx.outputs!.cloudWatchLogGroup.name; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'POST', + '/api/ds/query', + authToken, + { + queries: [ + { + datasource: { + type: 'cloudwatch', + uid: dataSourceUid, + }, + queryMode: 'Logs', + logGroups: [{ name: cloudWatchLogGroupName }], + expression: + 'fields @timestamp, @message | sort @timestamp desc | limit 10', + refId: 'A', + }, + ], + from: 'now-5m', + to: 'now', + }, + ); + assert.strictEqual(statusCode, 200, 'Expected query to succeed'); + + const data = (await body.json()) as { + results: Record }>; + }; + const frames = data.results?.A?.frames ?? []; + assert.ok( + frames.length > 0, + `Expected Grafana to return log frames for log group '${cloudWatchLogGroupName}'`, + ); + }); + }); + + it('should display traces data in the dashboard', async () => { + await requestEndpointWithExpectedStatus(ctx, ctx.config.usersPath, 200); + + const grafana = ctx.outputs!.logsAndTracesGrafana; + + const xRayDataSource = ( + grafana.connections[1] as studion.grafana.XRayConnection + ).dataSource; + const xRayDataSourceName = xRayDataSource.name as unknown as Unwrap< + typeof xRayDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + const { body: dsBody } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(xRayDataSourceName)}`, + authToken, + ); + const dsData = (await dsBody.json()) as Record; + const dataSourceUid = dsData.uid as string; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'POST', + '/api/ds/query', + authToken, + { + queries: [ + { + datasource: { + type: 'grafana-x-ray-datasource', + uid: dataSourceUid, + }, + queryType: 'getTraceSummaries', + refId: 'A', + }, + ], + from: 'now-5m', + to: 'now', + }, + ); + assert.strictEqual(statusCode, 200, 'Expected query to succeed'); + + const data = (await body.json()) as { + results: Record }>; + }; + const frames = data.results?.A?.frames ?? []; + assert.ok( + frames.length > 0, + `Expected Grafana to return trace frames from X-Ray`, + ); + }); + }); +} From 33aeb7e924fe114d4f00d55ba88bddce4d5ecf77 Mon Sep 17 00:00:00 2001 From: mandryllo Date: Fri, 17 Apr 2026 11:04:14 +0200 Subject: [PATCH 3/4] refactor: add limit and search text variables --- src/components/grafana/dashboards/builder.ts | 2 +- .../grafana/dashboards/logs-and-traces.ts | 19 +++-- src/components/grafana/panels/helpers.ts | 51 +----------- src/components/grafana/panels/logs.ts | 80 ++++++++++--------- src/components/grafana/panels/traces.ts | 13 --- src/components/grafana/panels/types.ts | 15 +++- src/components/grafana/variables/helpers.ts | 23 +++++- src/components/grafana/variables/index.ts | 4 +- src/components/grafana/variables/limit.ts | 14 ++++ src/components/grafana/variables/log-level.ts | 26 +++--- .../grafana/variables/search-text.ts | 5 ++ .../grafana/variables/status-code.ts | 26 +++--- src/components/grafana/variables/types.ts | 16 +++- 13 files changed, 155 insertions(+), 139 deletions(-) delete mode 100644 src/components/grafana/panels/traces.ts create mode 100644 src/components/grafana/variables/limit.ts create mode 100644 src/components/grafana/variables/search-text.ts diff --git a/src/components/grafana/dashboards/builder.ts b/src/components/grafana/dashboards/builder.ts index 04a944b0..af101e57 100644 --- a/src/components/grafana/dashboards/builder.ts +++ b/src/components/grafana/dashboards/builder.ts @@ -44,7 +44,7 @@ export class GrafanaDashboardBuilder { return this; } - withVariable(variable: Variable) { + addVariable(variable: Variable) { this.variables.push(variable); return this; diff --git a/src/components/grafana/dashboards/logs-and-traces.ts b/src/components/grafana/dashboards/logs-and-traces.ts index ec73937a..59260209 100644 --- a/src/components/grafana/dashboards/logs-and-traces.ts +++ b/src/components/grafana/dashboards/logs-and-traces.ts @@ -1,8 +1,10 @@ import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; import { GrafanaDashboardBuilder } from './builder'; import { createStatusCodeVariable } from '../variables/status-code'; +import { createLimitVariable } from '../variables/limit'; import { createLogLevelVariable } from '../variables/log-level'; -import { createLogsListWithFiltersPanel } from '../panels/logs'; +import { createLogsViewPanel } from '../panels/logs'; +import { createSearchTextVariable } from '../variables/search-text'; export namespace LogsAndTracesDashboard { export type Args = { @@ -17,23 +19,26 @@ export namespace LogsAndTracesDashboard { const defaults = { title: 'Logs & Traces', - dashboardConfig: {}, + dashboardConfig: { + refresh: '1m', + }, }; export function createLogsAndTracesDashboard( config: LogsAndTracesDashboard.Args, ): GrafanaDashboardBuilder.CreateDashboard { const argsWithDefaults = mergeWithDefaults(defaults, config); - const { title, logsDataSourceName, logGroupName, tracesDataSourceName } = - argsWithDefaults; + const { title, logsDataSourceName, logGroupName } = argsWithDefaults; return new GrafanaDashboardBuilder(config.name) .withConfig(argsWithDefaults.dashboardConfig) .withTitle(title) - .withVariable(createStatusCodeVariable()) - .withVariable(createLogLevelVariable()) + .addVariable(createSearchTextVariable()) + .addVariable(createStatusCodeVariable()) + .addVariable(createLogLevelVariable()) + .addVariable(createLimitVariable()) .addPanel( - createLogsListWithFiltersPanel({ + createLogsViewPanel({ logGroupName, dataSourceName: logsDataSourceName, }), diff --git a/src/components/grafana/panels/helpers.ts b/src/components/grafana/panels/helpers.ts index 63f3375b..aceb92dd 100644 --- a/src/components/grafana/panels/helpers.ts +++ b/src/components/grafana/panels/helpers.ts @@ -137,60 +137,13 @@ export function createBurnRatePanel( }; } -export function createLogsPanel( - title: string, - position: Panel.Position, - dataSource: string, - logGroupName: string, - expression: string, -): Panel { - return { - type: 'logs', - title, - gridPos: position, - datasource: dataSource, - targets: [ - { - expression, - logGroups: [{ name: logGroupName }], - queryMode: 'Logs', - }, - ], - fieldConfig: { - defaults: {}, - }, - }; -} - export function createTablePanel( - title: string, - position: Panel.Position, - dataSource: string, - queryType: string, -): Panel { - return { - type: 'table', - title, - gridPos: position, - datasource: dataSource, - targets: [ - { - queryType, - }, - ], - fieldConfig: { - defaults: {}, - }, - }; -} - -export function createTableForLogsPanel( title: string, position: Panel.Position, dataSource: string, logGroupName: string, expression: string, - transformation: Transformation, + transformations: Transformation[], ): Panel { return { type: 'table', @@ -204,7 +157,7 @@ export function createTableForLogsPanel( queryMode: 'Logs', }, ], - transformations: [transformation], + transformations, fieldConfig: { defaults: {}, }, diff --git a/src/components/grafana/panels/logs.ts b/src/components/grafana/panels/logs.ts index fd6b87a9..6bf66357 100644 --- a/src/components/grafana/panels/logs.ts +++ b/src/components/grafana/panels/logs.ts @@ -1,52 +1,56 @@ import { Panel } from './types'; -import { - createLogsPanel, - createTablePanel, - createTableForLogsPanel, -} from './helpers'; +import { createTablePanel } from './helpers'; -export function createLogsListPanel(config: { +export function createLogsViewPanel(config: { logGroupName: string; dataSourceName: string; }): Panel { - return createLogsPanel( - 'Logs', - { x: 0, y: 0, w: 24, h: 10 }, - config.dataSourceName, - config.logGroupName, - 'fields @timestamp, @message, @logStream\n| sort @timestamp desc\n| limit 20', - ); -} - -export function createLogsListWithFiltersPanel(config: { - logGroupName: string; - dataSourceName: string; -}): Panel { - return createTableForLogsPanel( + return createTablePanel( 'Logs', { x: 0, y: 0, w: 24, h: 12 }, config.dataSourceName, config.logGroupName, - `fields @timestamp\n| parse @message '"body":"*"' as body\n| parse @message '"res":{"statusCode":*}' as statusCode\n| parse @message '"severity_text":"*"' as logLevel\n| filter \${status_code}\n| filter \${log_level}\n| sort @timestamp desc\n| limit 20`, - { - id: 'organize', - options: { - renameByName: { - statusCode: 'Status Code', - logLevel: 'Log Level', - body: 'Body', - '@timestamp': 'Timestamp', + `fields @Timestamp + | parse @message '"body":"*"' as body + | parse @message '"res":{"statusCode":*}' as statusCode + | parse @message '"severity_text":"*"' as logLevel + | filter body like /\${search_text}/ + | filter \${status_code} + | filter \${log_level} + | sort @timestamp desc + | limit \${limit}`, + [ + { + id: 'organize', + options: { + renameByName: { + statusCode: 'Status Code', + logLevel: 'Log Level', + body: 'Body', + '@timestamp': 'Timestamp', + }, + indexByName: { + '@timestamp': 0, + statusCode: 1, + logLevel: 2, + body: 3, + }, + excludeByName: { + Value: true, + }, }, - indexByName: { - '@timestamp': 0, - statusCode: 1, - logLevel: 2, - body: 3, - }, - excludeByName: { - Value: true, + }, + { + id: 'sortBy', + options: { + sort: [ + { + field: 'Time', + desc: true, + }, + ], }, }, - }, + ], ); } diff --git a/src/components/grafana/panels/traces.ts b/src/components/grafana/panels/traces.ts deleted file mode 100644 index 7e6223f9..00000000 --- a/src/components/grafana/panels/traces.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Panel } from './types'; -import { createTablePanel } from './helpers'; - -export function createTracesListPanel(config: { - dataSourceName: string; -}): Panel { - return createTablePanel( - 'Traces', - { x: 0, y: 0, w: 24, h: 10 }, - config.dataSourceName, - 'getTraceSummaries', - ); -} diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts index 4bb87f7e..01c58d4a 100644 --- a/src/components/grafana/panels/types.ts +++ b/src/components/grafana/panels/types.ts @@ -11,7 +11,7 @@ export type Panel = { queryMode?: string; queryType?: string; }[]; - fieldConfig?: { + fieldConfig: { defaults: { unit?: string; min?: number; @@ -63,11 +63,20 @@ export type Threshold = { color: string; }; -export type Transformation = { - id: string; +export type OrganizeTransformation = { + id: 'organize'; options: { renameByName?: Record; excludeByName?: Record; indexByName?: Record; }; }; + +export type SortByTransformation = { + id: 'sortBy'; + options: { + sort: { field: string; desc: boolean }[]; + }; +}; + +export type Transformation = OrganizeTransformation | SortByTransformation; diff --git a/src/components/grafana/variables/helpers.ts b/src/components/grafana/variables/helpers.ts index 37bd3b8e..6ff6a58c 100644 --- a/src/components/grafana/variables/helpers.ts +++ b/src/components/grafana/variables/helpers.ts @@ -1,4 +1,9 @@ -import { BuildQuery, Variable, VariableOption } from './types'; +import { + BuildQuery, + CustomVariable, + VariableOption, + TextBoxVariable, +} from './types'; const buildQuery: BuildQuery = options => JSON.stringify(options); @@ -6,13 +11,25 @@ export function createCustomVariable( name: string, label: string, options: VariableOption[], -): Variable { + currentOption: VariableOption, +): CustomVariable { return { type: 'custom', name, label, query: buildQuery(options), - current: options[0], + current: currentOption, valuesFormat: 'json', }; } + +export function createTextBoxVariable( + name: string, + label: string, +): TextBoxVariable { + return { + type: 'textbox', + name, + label, + }; +} diff --git a/src/components/grafana/variables/index.ts b/src/components/grafana/variables/index.ts index 1c8f98cd..72db3b02 100644 --- a/src/components/grafana/variables/index.ts +++ b/src/components/grafana/variables/index.ts @@ -1,4 +1,6 @@ export * from './types'; export * from './helpers'; -export * from './status-code'; +export * from './limit'; export * from './log-level'; +export * from './search-text'; +export * from './status-code'; diff --git a/src/components/grafana/variables/limit.ts b/src/components/grafana/variables/limit.ts new file mode 100644 index 00000000..ae0cc723 --- /dev/null +++ b/src/components/grafana/variables/limit.ts @@ -0,0 +1,14 @@ +import { createCustomVariable } from './helpers'; + +const LIMITS = [ + { text: '20', value: 20 }, + { text: '50', value: 50 }, + { text: '100', value: 100 }, + { text: '250', value: 250 }, + { text: '500', value: 500 }, + { text: '1000', value: 1000 }, +]; + +export function createLimitVariable() { + return createCustomVariable('limit', 'Limit', LIMITS, LIMITS[0]); +} diff --git a/src/components/grafana/variables/log-level.ts b/src/components/grafana/variables/log-level.ts index f6c62fec..5292e752 100644 --- a/src/components/grafana/variables/log-level.ts +++ b/src/components/grafana/variables/log-level.ts @@ -1,13 +1,19 @@ -import { Variable } from './types'; import { createCustomVariable } from './helpers'; -export function createLogLevelVariable(): Variable { - return createCustomVariable('log_level', 'Log Level', [ - { text: 'info', value: "logLevel = 'info'" }, - { text: 'trace', value: "logLevel = 'trace'" }, - { text: 'debug', value: "logLevel = 'debug'" }, - { text: 'warn', value: "logLevel = 'warn'" }, - { text: 'error', value: "logLevel = 'error'" }, - { text: 'fatal', value: "logLevel = 'fatal'" }, - ]); +const LOG_LEVELS = [ + { text: 'trace', value: "logLevel = 'trace'" }, + { text: 'debug', value: "logLevel = 'debug'" }, + { text: 'info', value: "logLevel = 'info'" }, + { text: 'warn', value: "logLevel = 'warn'" }, + { text: 'error', value: "logLevel = 'error'" }, + { text: 'fatal', value: "logLevel = 'fatal'" }, +]; + +export function createLogLevelVariable() { + return createCustomVariable( + 'log_level', + 'Log Level', + LOG_LEVELS, + LOG_LEVELS[2], + ); } diff --git a/src/components/grafana/variables/search-text.ts b/src/components/grafana/variables/search-text.ts new file mode 100644 index 00000000..0390e76d --- /dev/null +++ b/src/components/grafana/variables/search-text.ts @@ -0,0 +1,5 @@ +import { createTextBoxVariable } from './helpers'; + +export function createSearchTextVariable() { + return createTextBoxVariable('search_text', 'Search Text'); +} diff --git a/src/components/grafana/variables/status-code.ts b/src/components/grafana/variables/status-code.ts index f071bb71..6c6a1c55 100644 --- a/src/components/grafana/variables/status-code.ts +++ b/src/components/grafana/variables/status-code.ts @@ -1,13 +1,19 @@ -import { Variable } from './types'; import { createCustomVariable } from './helpers'; -export function createStatusCodeVariable(): Variable { - return createCustomVariable('status_code', 'Status Code', [ - { text: 'N/A', value: '!ispresent(statusCode)' }, - { text: '1xx', value: 'statusCode >= 100 and statusCode < 200' }, - { text: '2xx', value: 'statusCode >= 200 and statusCode < 300' }, - { text: '3xx', value: 'statusCode >= 300 and statusCode < 400' }, - { text: '4xx', value: 'statusCode >= 400 and statusCode < 500' }, - { text: '5xx', value: 'statusCode >= 500 and statusCode < 600' }, - ]); +const STATUS_CODES = [ + { text: 'N/A', value: '!ispresent(statusCode)' }, + { text: '1xx', value: 'statusCode >= 100 and statusCode < 200' }, + { text: '2xx', value: 'statusCode >= 200 and statusCode < 300' }, + { text: '3xx', value: 'statusCode >= 300 and statusCode < 400' }, + { text: '4xx', value: 'statusCode >= 400 and statusCode < 500' }, + { text: '5xx', value: 'statusCode >= 500 and statusCode < 600' }, +]; + +export function createStatusCodeVariable() { + return createCustomVariable( + 'status_code', + 'Status Code', + STATUS_CODES, + STATUS_CODES[0], + ); } diff --git a/src/components/grafana/variables/types.ts b/src/components/grafana/variables/types.ts index b289b125..cf4c773b 100644 --- a/src/components/grafana/variables/types.ts +++ b/src/components/grafana/variables/types.ts @@ -1,15 +1,23 @@ export type VariableOption = { text: string; - value: string; + value: string | number; }; -export type Variable = { - type: string; +export type CustomVariable = { + type: 'custom'; name: string; label: string; query: string; current: VariableOption; - valuesFormat: string; + valuesFormat: 'json'; }; +export type TextBoxVariable = { + type: 'textbox'; + name: string; + label: string; +}; + +export type Variable = CustomVariable | TextBoxVariable; + export type BuildQuery = (options: VariableOption[]) => string; From f60cb6571c31661936d479ac37fdcf81deb49c47 Mon Sep 17 00:00:00 2001 From: mandryllo Date: Fri, 17 Apr 2026 11:20:19 +0200 Subject: [PATCH 4/4] refactor: remove test file --- tests/grafana/logs-and-traces-grafana.test.ts | 330 ------------------ 1 file changed, 330 deletions(-) delete mode 100644 tests/grafana/logs-and-traces-grafana.test.ts diff --git a/tests/grafana/logs-and-traces-grafana.test.ts b/tests/grafana/logs-and-traces-grafana.test.ts deleted file mode 100644 index b5ae5d61..00000000 --- a/tests/grafana/logs-and-traces-grafana.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { it } from 'node:test'; -import * as assert from 'node:assert'; -import * as studion from '@studion/infra-code-blocks'; -import { - GetRoleCommand, - GetRolePolicyCommand, - ListRolePoliciesCommand, -} from '@aws-sdk/client-iam'; -import { Unwrap } from '@pulumi/pulumi'; -import { backOff } from '../util'; -import { GrafanaTestContext } from './test-context'; -import { grafanaRequest, requestEndpointWithExpectedStatus } from './util'; - -export function testLogsAndTracesGrafana(ctx: GrafanaTestContext) { - it('should have created the IAM role with CloudWwatch logs inline policy', async () => { - const iamRole = ctx.outputs!.logsAndTracesGrafana.connections[0].role; - const grafanaCloudWatchLogsRoleArn = iamRole.arn as unknown as Unwrap< - typeof iamRole.arn - >; - const roleName = grafanaCloudWatchLogsRoleArn.split('/').pop()!; - const { Role } = await ctx.clients.iam.send( - new GetRoleCommand({ RoleName: roleName }), - ); - assert.ok(Role, 'Grafana IAM role should exist'); - - const { PolicyNames } = await ctx.clients.iam.send( - new ListRolePoliciesCommand({ RoleName: roleName }), - ); - assert.ok( - PolicyNames && PolicyNames.length > 0, - 'IAM role should have at least one inline policy', - ); - - const { PolicyDocument } = await ctx.clients.iam.send( - new GetRolePolicyCommand({ - RoleName: roleName, - PolicyName: PolicyNames[0], - }), - ); - const policy = JSON.parse(decodeURIComponent(PolicyDocument!)) as { - Statement: Array<{ Action: string[] }>; - }; - const actions = policy.Statement.flatMap(s => s.Action).sort(); - const expectedActions = [ - 'logs:DescribeLogGroups', - 'logs:GetLogGroupFields', - 'logs:StartQuery', - 'logs:StopQuery', - 'logs:GetQueryResults', - 'logs:GetLogEvents', - ].sort(); - assert.deepStrictEqual( - actions, - expectedActions, - 'CloudWatch logs policy actions do not match expected actions', - ); - }); - - it('should have created the IAM role with xRay inline policy', async () => { - const iamRole = ctx.outputs!.logsAndTracesGrafana.connections[1].role; - const grafanaXRayRoleArn = iamRole.arn as unknown as Unwrap< - typeof iamRole.arn - >; - const roleName = grafanaXRayRoleArn.split('/').pop()!; - const { Role } = await ctx.clients.iam.send( - new GetRoleCommand({ RoleName: roleName }), - ); - assert.ok(Role, 'Grafana IAM role should exist'); - - const { PolicyNames } = await ctx.clients.iam.send( - new ListRolePoliciesCommand({ RoleName: roleName }), - ); - assert.ok( - PolicyNames && PolicyNames.length > 0, - 'IAM role should have at least one inline policy', - ); - - const { PolicyDocument } = await ctx.clients.iam.send( - new GetRolePolicyCommand({ - RoleName: roleName, - PolicyName: PolicyNames[0], - }), - ); - const policy = JSON.parse(decodeURIComponent(PolicyDocument!)) as { - Statement: Array<{ Action: string[] }>; - }; - const actions = policy.Statement.flatMap(s => s.Action).sort(); - const expectedActions = [ - 'xray:BatchGetTraces', - 'xray:GetTraceSummaries', - 'xray:GetTraceGraph', - 'xray:GetGroups', - 'xray:GetTimeSeriesServiceStatistics', - 'xray:GetInsightSummaries', - 'xray:GetInsight', - 'xray:GetServiceGraph', - 'ec2:DescribeRegions', - ].sort(); - assert.deepStrictEqual( - actions, - expectedActions, - 'XRay policy actions do not match expected actions', - ); - }); - - it('should have created the CloudWatch data source', async () => { - const grafana = ctx.outputs!.logsAndTracesGrafana; - const cloudWatchDataSource = ( - grafana.connections[0] as studion.grafana.CloudWatchLogsConnection - ).dataSource; - const cloudWatchDataSourceName = - cloudWatchDataSource.name as unknown as Unwrap< - typeof cloudWatchDataSource.name - >; - - const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< - typeof grafana.serviceAccountToken.key - >; - - await backOff(async () => { - const { body, statusCode } = await grafanaRequest( - ctx, - 'GET', - `/api/datasources/name/${encodeURIComponent(cloudWatchDataSourceName)}`, - authToken, - ); - assert.strictEqual(statusCode, 200, 'Expected data source to exist'); - - const data = (await body.json()) as Record; - assert.strictEqual( - data.type, - 'cloudwatch', - 'Expected CloudWatch data source type', - ); - }); - }); - - it('should have created the XRay data source', async () => { - const grafana = ctx.outputs!.logsAndTracesGrafana; - const xRayDataSource = ( - grafana.connections[1] as studion.grafana.XRayConnection - ).dataSource; - const xRayDataSourceName = xRayDataSource.name as unknown as Unwrap< - typeof xRayDataSource.name - >; - - const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< - typeof grafana.serviceAccountToken.key - >; - - await backOff(async () => { - const { body, statusCode } = await grafanaRequest( - ctx, - 'GET', - `/api/datasources/name/${encodeURIComponent(xRayDataSourceName)}`, - authToken, - ); - assert.strictEqual(statusCode, 200, 'Expected data source to exist'); - - const data = (await body.json()) as Record; - assert.strictEqual( - data.type, - 'grafana-x-ray-datasource', - 'Expected XRay data source type', - ); - }); - }); - - it('should have created the dashboard with expected panels', async () => { - const grafana = ctx.outputs!.logsAndTracesGrafana; - const dashboard = grafana.dashboards[0]; - const dashboardUid = dashboard.uid as unknown as Unwrap< - typeof dashboard.uid - >; - - const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< - typeof grafana.serviceAccountToken.key - >; - - await backOff(async () => { - const { body, statusCode } = await grafanaRequest( - ctx, - 'GET', - `/api/dashboards/uid/${dashboardUid}`, - authToken, - ); - assert.strictEqual(statusCode, 200, 'Expected dashboard to exist'); - - const data = (await body.json()) as { - dashboard: { title: string; panels: Array<{ title: string }> }; - }; - assert.strictEqual( - data.dashboard.title, - 'ICB Grafana Test Logs & Traces', - 'Expected dashboard title to match', - ); - - const panelTitles = data.dashboard.panels.map(p => p.title).sort(); - const expectedPanels = ['Logs', 'Traces']; - assert.deepStrictEqual( - panelTitles, - expectedPanels.sort(), - 'Dashboard panels do not match expected panels', - ); - }); - }); - - it('should display logs data in the dashboard', async () => { - await requestEndpointWithExpectedStatus(ctx, ctx.config.usersPath, 200); - - const grafana = ctx.outputs!.logsAndTracesGrafana; - - const cloudWatchLogsDataSource = ( - grafana.connections[0] as studion.grafana.CloudWatchLogsConnection - ).dataSource; - const cloudWatchLogsDataSourceName = - cloudWatchLogsDataSource.name as unknown as Unwrap< - typeof cloudWatchLogsDataSource.name - >; - - const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< - typeof grafana.serviceAccountToken.key - >; - - const { body: dsBody } = await grafanaRequest( - ctx, - 'GET', - `/api/datasources/name/${encodeURIComponent(cloudWatchLogsDataSourceName)}`, - authToken, - ); - const dsData = (await dsBody.json()) as Record; - const dataSourceUid = dsData.uid as string; - const cloudWatchLogGroupName = ctx.outputs!.cloudWatchLogGroup.name; - - await backOff(async () => { - const { body, statusCode } = await grafanaRequest( - ctx, - 'POST', - '/api/ds/query', - authToken, - { - queries: [ - { - datasource: { - type: 'cloudwatch', - uid: dataSourceUid, - }, - queryMode: 'Logs', - logGroups: [{ name: cloudWatchLogGroupName }], - expression: - 'fields @timestamp, @message | sort @timestamp desc | limit 10', - refId: 'A', - }, - ], - from: 'now-5m', - to: 'now', - }, - ); - assert.strictEqual(statusCode, 200, 'Expected query to succeed'); - - const data = (await body.json()) as { - results: Record }>; - }; - const frames = data.results?.A?.frames ?? []; - assert.ok( - frames.length > 0, - `Expected Grafana to return log frames for log group '${cloudWatchLogGroupName}'`, - ); - }); - }); - - it('should display traces data in the dashboard', async () => { - await requestEndpointWithExpectedStatus(ctx, ctx.config.usersPath, 200); - - const grafana = ctx.outputs!.logsAndTracesGrafana; - - const xRayDataSource = ( - grafana.connections[1] as studion.grafana.XRayConnection - ).dataSource; - const xRayDataSourceName = xRayDataSource.name as unknown as Unwrap< - typeof xRayDataSource.name - >; - - const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< - typeof grafana.serviceAccountToken.key - >; - - const { body: dsBody } = await grafanaRequest( - ctx, - 'GET', - `/api/datasources/name/${encodeURIComponent(xRayDataSourceName)}`, - authToken, - ); - const dsData = (await dsBody.json()) as Record; - const dataSourceUid = dsData.uid as string; - - await backOff(async () => { - const { body, statusCode } = await grafanaRequest( - ctx, - 'POST', - '/api/ds/query', - authToken, - { - queries: [ - { - datasource: { - type: 'grafana-x-ray-datasource', - uid: dataSourceUid, - }, - queryType: 'getTraceSummaries', - refId: 'A', - }, - ], - from: 'now-5m', - to: 'now', - }, - ); - assert.strictEqual(statusCode, 200, 'Expected query to succeed'); - - const data = (await body.json()) as { - results: Record }>; - }; - const frames = data.results?.A?.frames ?? []; - assert.ok( - frames.length > 0, - `Expected Grafana to return trace frames from X-Ray`, - ); - }); - }); -}