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/builder.ts b/src/components/grafana/dashboards/builder.ts index 483fc0ba..af101e57 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; } + addVariable(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/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..59260209 --- /dev/null +++ b/src/components/grafana/dashboards/logs-and-traces.ts @@ -0,0 +1,47 @@ +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 { createLogsViewPanel } from '../panels/logs'; +import { createSearchTextVariable } from '../variables/search-text'; + +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: { + refresh: '1m', + }, +}; + +export function createLogsAndTracesDashboard( + config: LogsAndTracesDashboard.Args, +): GrafanaDashboardBuilder.CreateDashboard { + const argsWithDefaults = mergeWithDefaults(defaults, config); + const { title, logsDataSourceName, logGroupName } = argsWithDefaults; + + return new GrafanaDashboardBuilder(config.name) + .withConfig(argsWithDefaults.dashboardConfig) + .withTitle(title) + .addVariable(createSearchTextVariable()) + .addVariable(createStatusCodeVariable()) + .addVariable(createLogLevelVariable()) + .addVariable(createLimitVariable()) + .addPanel( + createLogsViewPanel({ + logGroupName, + dataSourceName: logsDataSourceName, + }), + ) + .build(); +} diff --git a/src/components/grafana/panels/helpers.ts b/src/components/grafana/panels/helpers.ts index 97a26696..aceb92dd 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', @@ -136,3 +136,30 @@ export function createBurnRatePanel( }, }; } + +export function createTablePanel( + title: string, + position: Panel.Position, + dataSource: string, + logGroupName: string, + expression: string, + transformations: Transformation[], +): Panel { + return { + type: 'table', + title, + gridPos: position, + datasource: dataSource, + targets: [ + { + expression, + logGroups: [{ name: logGroupName }], + queryMode: 'Logs', + }, + ], + transformations, + fieldConfig: { + defaults: {}, + }, + }; +} diff --git a/src/components/grafana/panels/logs.ts b/src/components/grafana/panels/logs.ts new file mode 100644 index 00000000..6bf66357 --- /dev/null +++ b/src/components/grafana/panels/logs.ts @@ -0,0 +1,56 @@ +import { Panel } from './types'; +import { createTablePanel } from './helpers'; + +export function createLogsViewPanel(config: { + logGroupName: string; + dataSourceName: string; +}): Panel { + return createTablePanel( + 'Logs', + { x: 0, y: 0, w: 24, h: 12 }, + config.dataSourceName, + config.logGroupName, + `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, + }, + }, + }, + { + id: 'sortBy', + options: { + sort: [ + { + field: 'Time', + desc: true, + }, + ], + }, + }, + ], + ); +} diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts index 889b8785..01c58d4a 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: { @@ -25,6 +29,7 @@ export type Panel = { }; }; }; + transformations?: Transformation[]; options?: { colorMode?: string; graphMode?: string; @@ -57,3 +62,21 @@ export type Threshold = { value: number | null; color: 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 new file mode 100644 index 00000000..6ff6a58c --- /dev/null +++ b/src/components/grafana/variables/helpers.ts @@ -0,0 +1,35 @@ +import { + BuildQuery, + CustomVariable, + VariableOption, + TextBoxVariable, +} from './types'; + +const buildQuery: BuildQuery = options => JSON.stringify(options); + +export function createCustomVariable( + name: string, + label: string, + options: VariableOption[], + currentOption: VariableOption, +): CustomVariable { + return { + type: 'custom', + name, + label, + query: buildQuery(options), + 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 new file mode 100644 index 00000000..72db3b02 --- /dev/null +++ b/src/components/grafana/variables/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './helpers'; +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 new file mode 100644 index 00000000..5292e752 --- /dev/null +++ b/src/components/grafana/variables/log-level.ts @@ -0,0 +1,19 @@ +import { createCustomVariable } from './helpers'; + +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 new file mode 100644 index 00000000..6c6a1c55 --- /dev/null +++ b/src/components/grafana/variables/status-code.ts @@ -0,0 +1,19 @@ +import { createCustomVariable } from './helpers'; + +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 new file mode 100644 index 00000000..cf4c773b --- /dev/null +++ b/src/components/grafana/variables/types.ts @@ -0,0 +1,23 @@ +export type VariableOption = { + text: string; + value: string | number; +}; + +export type CustomVariable = { + type: 'custom'; + name: string; + label: string; + query: string; + current: VariableOption; + valuesFormat: 'json'; +}; + +export type TextBoxVariable = { + type: 'textbox'; + name: string; + label: string; +}; + +export type Variable = CustomVariable | TextBoxVariable; + +export type BuildQuery = (options: VariableOption[]) => string;