diff --git a/src/components/grafana/builder.ts b/src/components/grafana/builder.ts index a930077a..ad12e5e6 100644 --- a/src/components/grafana/builder.ts +++ b/src/components/grafana/builder.ts @@ -6,16 +6,27 @@ import { XRayConnection, } from './connections'; import { Grafana } from './grafana'; +import type { GrafanaDashboardBuilder } from './dashboards/builder'; +import { createSloDashboard, SloDashboard } from './dashboards/slo'; export class GrafanaBuilder { private readonly name: string; - private readonly connectionBuilders: GrafanaConnection.ConnectionBuilder[] = + private readonly connectionBuilders: GrafanaConnection.CreateConnection[] = []; + private readonly dashboardBuilders: GrafanaDashboardBuilder.CreateDashboard[] = + []; + private folderName?: string; constructor(name: string) { this.name = name; } + public withFolderName(folderName: string): this { + this.folderName = folderName; + + return this; + } + public addAmp(name: string, args: AMPConnection.Args): this { this.connectionBuilders.push(opts => new AMPConnection(name, args, opts)); @@ -39,16 +50,36 @@ export class GrafanaBuilder { return this; } - public addConnection(builder: GrafanaConnection.ConnectionBuilder): this { + public addConnection(builder: GrafanaConnection.CreateConnection): this { this.connectionBuilders.push(builder); return this; } + public addSloDashboard(config: SloDashboard.Args): this { + this.dashboardBuilders.push(createSloDashboard(config)); + + return this; + } + + public addDashboard( + dashboard: GrafanaDashboardBuilder.CreateDashboard, + ): this { + this.dashboardBuilders.push(dashboard); + + return this; + } + public build(opts: pulumi.ComponentResourceOptions = {}): Grafana { if (!this.connectionBuilders.length) { throw new Error( - 'At least one connection is required. Call addConnection() to add custom connection or use one of existing connection builders.', + 'At least one connection is required. Call addConnection() to add a custom connection or use one of the existing connection builders.', + ); + } + + if (!this.dashboardBuilders.length) { + throw new Error( + 'At least one dashboard is required. Call addDashboard() to add a custom dashboard or use one of the existing dashboard builders.', ); } @@ -56,6 +87,8 @@ export class GrafanaBuilder { this.name, { connectionBuilders: this.connectionBuilders, + dashboardBuilders: this.dashboardBuilders, + folderName: this.folderName, }, opts, ); diff --git a/src/components/grafana/connections/amp-connection.ts b/src/components/grafana/connections/amp-connection.ts index 1afd0240..c140fced 100644 --- a/src/components/grafana/connections/amp-connection.ts +++ b/src/components/grafana/connections/amp-connection.ts @@ -12,19 +12,21 @@ export namespace AMPConnection { endpoint: pulumi.Input; region?: string; pluginVersion?: string; + installPlugin?: boolean; }; } const defaults = { - pluginVersion: 'latest', region: awsConfig.require('region'), + pluginVersion: 'latest', + installPlugin: true, }; export class AMPConnection extends GrafanaConnection { public readonly name: string; public readonly dataSource: grafana.oss.DataSource; - public readonly plugin: grafana.cloud.PluginInstallation; public readonly rolePolicy: aws.iam.RolePolicy; + public readonly plugin?: grafana.cloud.PluginInstallation; constructor( name: string, @@ -38,7 +40,11 @@ export class AMPConnection extends GrafanaConnection { this.name = name; this.rolePolicy = this.createRolePolicy(); - this.plugin = this.createPlugin(argsWithDefaults.pluginVersion); + + if (argsWithDefaults.installPlugin) { + this.plugin = this.createPlugin(argsWithDefaults.pluginVersion); + } + this.dataSource = this.createDataSource( argsWithDefaults.region, argsWithDefaults.endpoint, @@ -91,12 +97,10 @@ export class AMPConnection extends GrafanaConnection { region: string, endpoint: AMPConnection.Args['endpoint'], ): grafana.oss.DataSource { - const dataSourceName = `${this.name}-amp-datasource`; - return new grafana.oss.DataSource( - dataSourceName, + `${this.name}-amp-datasource`, { - name: dataSourceName, + name: this.dataSourceName, type: pluginName, url: endpoint, jsonDataEncoded: pulumi.jsonStringify({ @@ -106,7 +110,7 @@ export class AMPConnection extends GrafanaConnection { sigV4AssumeRoleArn: this.role.arn, }), }, - { dependsOn: [this.plugin], parent: this }, + { dependsOn: this.plugin ? [this.plugin] : [], parent: this }, ); } } diff --git a/src/components/grafana/connections/cloudwatch-logs-connection.ts b/src/components/grafana/connections/cloudwatch-logs-connection.ts index 98ba799d..a8066948 100644 --- a/src/components/grafana/connections/cloudwatch-logs-connection.ts +++ b/src/components/grafana/connections/cloudwatch-logs-connection.ts @@ -67,12 +67,10 @@ export class CloudWatchLogsConnection extends GrafanaConnection { } private createDataSource(region: string): grafana.oss.DataSource { - const dataSourceName = `${this.name}-cloudwatch-logs-datasource`; - return new grafana.oss.DataSource( - dataSourceName, + `${this.name}-cloudwatch-logs-datasource`, { - name: dataSourceName, + name: this.dataSourceName, type: 'cloudwatch', jsonDataEncoded: pulumi.jsonStringify({ authType: 'grafana_assume_role', diff --git a/src/components/grafana/connections/connection.ts b/src/components/grafana/connections/connection.ts index 98efc36a..c48181da 100644 --- a/src/components/grafana/connections/connection.ts +++ b/src/components/grafana/connections/connection.ts @@ -8,9 +8,10 @@ const grafanaConfig = new pulumi.Config('grafana'); export namespace GrafanaConnection { export type Args = { awsAccountId: string; + dataSourceName?: string; }; - export type ConnectionBuilder = ( + export type CreateConnection = ( opts: pulumi.ComponentResourceOptions, ) => GrafanaConnection; } @@ -19,6 +20,7 @@ export abstract class GrafanaConnection extends pulumi.ComponentResource { public readonly name: string; public readonly role: aws.iam.Role; public abstract readonly dataSource: grafana.oss.DataSource; + protected readonly dataSourceName: string; constructor( type: string, @@ -30,6 +32,8 @@ export abstract class GrafanaConnection extends pulumi.ComponentResource { this.name = name; + this.dataSourceName = args.dataSourceName ?? `${name}-datasource`; + this.role = this.createIamRole(args.awsAccountId); this.registerOutputs(); diff --git a/src/components/grafana/connections/xray-connection.ts b/src/components/grafana/connections/xray-connection.ts index fec9f101..bbfc881e 100644 --- a/src/components/grafana/connections/xray-connection.ts +++ b/src/components/grafana/connections/xray-connection.ts @@ -11,19 +11,21 @@ export namespace XRayConnection { export type Args = GrafanaConnection.Args & { region?: string; pluginVersion?: string; + installPlugin?: boolean; }; } const defaults = { - pluginVersion: 'latest', region: awsConfig.require('region'), + pluginVersion: 'latest', + installPlugin: true, }; export class XRayConnection extends GrafanaConnection { public readonly name: string; public readonly dataSource: grafana.oss.DataSource; - public readonly plugin: grafana.cloud.PluginInstallation; public readonly rolePolicy: aws.iam.RolePolicy; + public readonly plugin?: grafana.cloud.PluginInstallation; constructor( name: string, @@ -37,7 +39,11 @@ export class XRayConnection extends GrafanaConnection { this.name = name; this.rolePolicy = this.createRolePolicy(); - this.plugin = this.createPlugin(argsWithDefaults.pluginVersion); + + if (argsWithDefaults.installPlugin) { + this.plugin = this.createPlugin(argsWithDefaults.pluginVersion); + } + this.dataSource = this.createDataSource(argsWithDefaults.region); this.registerOutputs(); @@ -89,12 +95,10 @@ export class XRayConnection extends GrafanaConnection { } private createDataSource(region: string): grafana.oss.DataSource { - const dataSourceName = `${this.name}-x-ray-datasource`; - return new grafana.oss.DataSource( - dataSourceName, + `${this.name}-x-ray-datasource`, { - name: dataSourceName, + name: this.dataSourceName, type: pluginName, jsonDataEncoded: pulumi.jsonStringify({ authType: 'grafana_assume_role', @@ -102,7 +106,7 @@ export class XRayConnection extends GrafanaConnection { defaultRegion: region, }), }, - { dependsOn: [this.plugin], parent: this }, + { dependsOn: this.plugin ? [this.plugin] : [], parent: this }, ); } } diff --git a/src/components/grafana/dashboards/builder.ts b/src/components/grafana/dashboards/builder.ts new file mode 100644 index 00000000..63e719ab --- /dev/null +++ b/src/components/grafana/dashboards/builder.ts @@ -0,0 +1,72 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; +import { Panel } from '../panels/types'; +import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; + +export namespace GrafanaDashboardBuilder { + export type Config = { + timezone?: string; + refresh?: string; + }; + + export type CreateDashboard = ( + folder?: grafana.oss.Folder, + opts?: pulumi.ComponentResourceOptions, + ) => grafana.oss.Dashboard; +} + +const defaults = { + timezone: 'browser', + refresh: '10s', +}; + +export class GrafanaDashboardBuilder { + private readonly name: string; + private readonly title: string; + private readonly panels: Panel[] = []; + private configuration: GrafanaDashboardBuilder.Config = {}; + + constructor(name: string, title: string) { + this.name = name; + this.title = title; + } + + withConfig(options: GrafanaDashboardBuilder.Config): this { + this.configuration = options; + + return this; + } + + addPanel(panel: Panel): this { + this.panels.push(panel); + + return this; + } + + build(): GrafanaDashboardBuilder.CreateDashboard { + if (!this.panels.length) { + throw new Error( + 'At least one panel is required. Call addPanel() to add a panel.', + ); + } + + const { name, title, panels } = this; + const options = mergeWithDefaults(defaults, this.configuration); + + return (folder, opts) => { + return new grafana.oss.Dashboard( + `${name}-dashboard`, + { + folder: folder?.uid, + configJson: pulumi.jsonStringify({ + title, + timezone: options.timezone, + refresh: options.refresh, + panels, + }), + }, + opts, + ); + }; + } +} diff --git a/src/components/grafana/dashboards/index.ts b/src/components/grafana/dashboards/index.ts index 6e0be25c..d4240168 100644 --- a/src/components/grafana/dashboards/index.ts +++ b/src/components/grafana/dashboards/index.ts @@ -1,2 +1,2 @@ -export { default as WebServerSloDashboardBuilder } from './web-server-slo'; -export * as panel from './panels'; +export { GrafanaDashboardBuilder as DashboardBuilder } from './builder'; +export { createSloDashboard } from './slo'; diff --git a/src/components/grafana/dashboards/slo.ts b/src/components/grafana/dashboards/slo.ts new file mode 100644 index 00000000..2c55e17c --- /dev/null +++ b/src/components/grafana/dashboards/slo.ts @@ -0,0 +1,56 @@ +import { mergeWithDefaults } from '../../../shared/merge-with-defaults'; +import { GrafanaDashboardBuilder } from './builder'; +import { queries as promQ } from '../../prometheus'; +import { + createAvailabilityPanel, + createAvailabilityBurnRatePanel, +} from '../panels/availability'; +import { + createSuccessRatePanel, + createSuccessRateTimeSeriesPanel, + createSuccessRateBurnRatePanel, +} from '../panels/success-rate'; +import { + createLatencyPanel, + createLatencyPercentilePanel, + createLatencyPercentagePanel, + createLatencyBurnRatePanel, +} from '../panels/latency'; + +export namespace SloDashboard { + export type Args = { + name: string; + title: string; + ampNamespace: string; + filter: string; + dataSourceName: string; + target?: number; + window?: promQ.TimeRange; + shortWindow?: promQ.TimeRange; + targetLatency?: number; + }; +} + +const defaults = { + target: 0.99, + window: '30d', + shortWindow: '5m', + targetLatency: 250, +}; + +export function createSloDashboard( + config: SloDashboard.Args, +): GrafanaDashboardBuilder.CreateDashboard { + const argsWithDefaults = mergeWithDefaults(defaults, config); + return new GrafanaDashboardBuilder(config.name, argsWithDefaults.title) + .addPanel(createAvailabilityPanel(argsWithDefaults)) + .addPanel(createAvailabilityBurnRatePanel(argsWithDefaults)) + .addPanel(createSuccessRatePanel(argsWithDefaults)) + .addPanel(createSuccessRateTimeSeriesPanel(argsWithDefaults)) + .addPanel(createSuccessRateBurnRatePanel(argsWithDefaults)) + .addPanel(createLatencyPanel(argsWithDefaults)) + .addPanel(createLatencyPercentilePanel(argsWithDefaults)) + .addPanel(createLatencyPercentagePanel(argsWithDefaults)) + .addPanel(createLatencyBurnRatePanel(argsWithDefaults)) + .build(); +} diff --git a/src/components/grafana/dashboards/types.ts b/src/components/grafana/dashboards/types.ts deleted file mode 100644 index 1a561bee..00000000 --- a/src/components/grafana/dashboards/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as pulumi from '@pulumi/pulumi'; -import * as grafana from '@pulumiverse/grafana'; - -// TODO: Should we prefix all namespaces with `Studion` -export namespace Grafana { - // TODO: Create SLO abstraction that enables configuring: - // - panels (long-window SLI, long-window error budget) - // - alerts (long-window burn, short-window burn) - export type Threshold = { - value: number | null; - color: string; - }; - export type Metric = { - label: string; - query: string; - thresholds: Threshold[]; - }; - - export type Args = { - title: pulumi.Input; - provider: pulumi.Input; - tags: pulumi.Input[]>; - }; - - export type Panel = { - title: string; - gridPos: Panel.Position; - type: string; - datasource: string; - targets: { - expr: string; - legendFormat: string; - }[]; - fieldConfig: { - defaults: { - unit?: string; - min?: number; - max?: number; - color?: { - mode: string; - }; - thresholds?: { - mode: string; - steps: Threshold[]; - }; - custom?: { - lineInterpolation?: string; - spanNulls: boolean; - }; - }; - }; - options?: { - colorMode?: string; - graphMode?: string; - justifyMode?: string; - textMode?: string; - reduceOptions?: { - calcs?: string[]; - fields?: string; - values?: boolean; - }; - }; - }; - - export namespace Panel { - export type Position = { - x: number; - y: number; - w: number; - h: number; - }; - } -} diff --git a/src/components/grafana/dashboards/web-server-slo.ts b/src/components/grafana/dashboards/web-server-slo.ts deleted file mode 100644 index 94ae90ea..00000000 --- a/src/components/grafana/dashboards/web-server-slo.ts +++ /dev/null @@ -1,234 +0,0 @@ -import * as pulumi from '@pulumi/pulumi'; -import * as grafana from '@pulumiverse/grafana'; -import { queries as promQ } from '../../prometheus'; -import { Grafana } from './types'; -import { - createBurnRatePanel, - createStatPercentagePanel, - createTimeSeriesPanel, - createTimeSeriesPercentagePanel, -} from './panels'; - -class WebServerSloDashboardBuilder { - name: string; - title: pulumi.Output; - panels: Grafana.Panel[] = []; - tags?: pulumi.Output; - - constructor(name: string, args: Grafana.Args) { - this.name = name; - this.title = pulumi.output(args.title); - } - - withAvailability( - target: number, - window: promQ.TimeRange, - dataSource: string, - prometheusNamespace: string, - ): this { - const availabilityPercentage = promQ.getAvailabilityPercentageQuery( - prometheusNamespace, - window, - ); - const availabilityBurnRate = promQ.getBurnRateQuery( - promQ.getAvailabilityQuery(prometheusNamespace, '1h'), - target, - ); - - const availabilitySloPanel = createStatPercentagePanel( - 'Availability', - { x: 0, y: 0, w: 8, h: 8 }, - dataSource, - { - label: 'Availability', - query: availabilityPercentage, - thresholds: [], - }, - ); - const availabilityBurnRatePanel = createBurnRatePanel( - 'Availability Burn Rate', - { x: 0, y: 8, w: 8, h: 4 }, - dataSource, - { - label: 'Burn Rate', - query: availabilityBurnRate, - thresholds: [], - }, - ); - - this.panels.push(availabilitySloPanel, availabilityBurnRatePanel); - - return this; - } - - withSuccessRate( - target: number, - window: promQ.TimeRange, - shortWindow: promQ.TimeRange, - filter: string, - dataSource: string, - prometheusNamespace: string, - ): this { - const successRateSlo = promQ.getSuccessPercentageQuery( - prometheusNamespace, - window, - filter, - ); - const successRateBurnRate = promQ.getBurnRateQuery( - promQ.getSuccessRateQuery(prometheusNamespace, '1h', filter), - target, - ); - const successRate = promQ.getSuccessPercentageQuery( - prometheusNamespace, - shortWindow, - filter, - ); - - const successRateSloPanel = createStatPercentagePanel( - 'Success Rate', - { x: 8, y: 0, w: 8, h: 8 }, - dataSource, - { - label: 'Success Rate', - query: successRateSlo, - thresholds: [], - }, - ); - const successRatePanel = createTimeSeriesPercentagePanel( - 'HTTP Request Success Rate', - { x: 0, y: 16, w: 12, h: 8 }, - dataSource, - { - label: 'Success Rate', - query: successRate, - thresholds: [], - }, - ); - const successRateBurnRatePanel = createBurnRatePanel( - 'Success Rate Burn Rate', - { x: 8, y: 8, w: 8, h: 4 }, - dataSource, - { - label: 'Burn Rate', - query: successRateBurnRate, - thresholds: [], - }, - ); - - this.panels.push( - successRateSloPanel, - successRatePanel, - successRateBurnRatePanel, - ); - - return this; - } - - withLatency( - target: number, - targetLatency: number, - window: promQ.TimeRange, - shortWindow: promQ.TimeRange, - filter: string, - dataSource: string, - prometheusNamespace: string, - ): this { - const latencySlo = promQ.getLatencyPercentageQuery( - prometheusNamespace, - window, - targetLatency, - filter, - ); - const latencyBurnRate = promQ.getBurnRateQuery( - promQ.getLatencyRateQuery(prometheusNamespace, '1h', targetLatency), - target, - ); - const percentileLatency = promQ.getPercentileLatencyQuery( - prometheusNamespace, - shortWindow, - target, - filter, - ); - const latencyBelowThreshold = promQ.getLatencyPercentageQuery( - prometheusNamespace, - shortWindow, - targetLatency, - filter, - ); - - const latencySloPanel = createStatPercentagePanel( - 'Request % below 250ms', - { x: 16, y: 0, w: 8, h: 8 }, - dataSource, - { - label: 'Request % below 250ms', - query: latencySlo, - thresholds: [], - }, - ); - const percentileLatencyPanel = createTimeSeriesPanel( - '99th Percentile Latency', - { x: 12, y: 16, w: 12, h: 8 }, - dataSource, - { - label: '99th Percentile Latency', - query: percentileLatency, - thresholds: [], - }, - 'ms', - ); - const latencyPercentagePanel = createTimeSeriesPercentagePanel( - 'Request percentage below 250ms', - { x: 0, y: 24, w: 12, h: 8 }, - dataSource, - { - label: 'Request percentage below 250ms', - query: latencyBelowThreshold, - thresholds: [], - }, - ); - const latencyBurnRatePanel = createBurnRatePanel( - 'Latency Burn Rate', - { x: 16, y: 8, w: 8, h: 4 }, - dataSource, - { - label: 'Burn Rate', - query: latencyBurnRate, - thresholds: [], - }, - ); - - this.panels.push( - latencySloPanel, - percentileLatencyPanel, - latencyPercentagePanel, - latencyBurnRatePanel, - ); - - return this; - } - - build( - provider: pulumi.Output, - ): pulumi.Output { - return pulumi - .all([this.title, this.panels, provider, this.tags]) - .apply(([title, panels, provider, tags]) => { - return new grafana.oss.Dashboard( - this.name, - { - configJson: JSON.stringify({ - title, - tags, - timezone: 'browser', - refresh: '10s', - panels, - }), - }, - { provider }, - ); - }); - } -} - -export default WebServerSloDashboardBuilder; diff --git a/src/components/grafana/grafana.ts b/src/components/grafana/grafana.ts index a81bdf5d..e522e87c 100644 --- a/src/components/grafana/grafana.ts +++ b/src/components/grafana/grafana.ts @@ -1,15 +1,21 @@ import * as pulumi from '@pulumi/pulumi'; +import * as grafana from '@pulumiverse/grafana'; +import type { GrafanaDashboardBuilder } from './dashboards/builder'; import { GrafanaConnection } from './connections'; export namespace Grafana { export type Args = { - connectionBuilders: GrafanaConnection.ConnectionBuilder[]; + connectionBuilders: GrafanaConnection.CreateConnection[]; + dashboardBuilders: GrafanaDashboardBuilder.CreateDashboard[]; + folderName?: string; }; } export class Grafana extends pulumi.ComponentResource { public readonly name: string; public readonly connections: GrafanaConnection[]; + public readonly folder: grafana.oss.Folder; + public readonly dashboards: grafana.oss.Dashboard[]; constructor( name: string, @@ -20,10 +26,20 @@ export class Grafana extends pulumi.ComponentResource { this.name = name; - this.connections = args.connectionBuilders.map(build => - build({ parent: this }), + this.connections = args.connectionBuilders.map(build => { + return build({ parent: this }); + }); + + this.folder = new grafana.oss.Folder( + `${this.name}-folder`, + { title: args.folderName ?? `${this.name}-ICB-GENERATED` }, + { parent: this }, ); + this.dashboards = args.dashboardBuilders.map(build => { + return build(this.folder, { parent: this.folder }); + }); + this.registerOutputs(); } } diff --git a/src/components/grafana/index.ts b/src/components/grafana/index.ts index ee49c030..ae96364f 100644 --- a/src/components/grafana/index.ts +++ b/src/components/grafana/index.ts @@ -1,4 +1,5 @@ export * as dashboard from './dashboards'; +export * as panels from './panels'; export { GrafanaConnection, AMPConnection, diff --git a/src/components/grafana/panels/availability.ts b/src/components/grafana/panels/availability.ts new file mode 100644 index 00000000..1ad3466e --- /dev/null +++ b/src/components/grafana/panels/availability.ts @@ -0,0 +1,45 @@ +import { queries as promQ } from '../../prometheus'; +import { Panel } from './types'; +import { createStatPercentagePanel, createBurnRatePanel } from './helpers'; + +export function createAvailabilityPanel(config: { + target: number; + window: promQ.TimeRange; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createStatPercentagePanel( + 'Availability', + { x: 0, y: 0, w: 8, h: 8 }, + config.dataSourceName, + { + label: 'Availability', + query: promQ.getAvailabilityPercentageQuery( + config.ampNamespace, + config.window, + ), + thresholds: [], + }, + ); +} + +export function createAvailabilityBurnRatePanel(config: { + target: number; + window: promQ.TimeRange; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createBurnRatePanel( + 'Availability Burn Rate', + { x: 0, y: 8, w: 8, h: 4 }, + config.dataSourceName, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getAvailabilityQuery(config.ampNamespace, '1h'), + config.target, + ), + thresholds: [], + }, + ); +} diff --git a/src/components/grafana/dashboards/panels.ts b/src/components/grafana/panels/helpers.ts similarity index 86% rename from src/components/grafana/dashboards/panels.ts rename to src/components/grafana/panels/helpers.ts index c410cd6c..97a26696 100644 --- a/src/components/grafana/dashboards/panels.ts +++ b/src/components/grafana/panels/helpers.ts @@ -1,4 +1,4 @@ -import { Grafana } from './types'; +import { Panel, Metric } from './types'; const percentageFieldConfig = { unit: 'percent', @@ -8,10 +8,10 @@ const percentageFieldConfig = { export function createStatPercentagePanel( title: string, - position: Grafana.Panel.Position, + position: Panel.Position, dataSource: string, - metric: Grafana.Metric, -): Grafana.Panel { + metric: Metric, +): Panel { return { title, gridPos: position, @@ -41,10 +41,10 @@ export function createStatPercentagePanel( export function createTimeSeriesPercentagePanel( title: string, - position: Grafana.Panel.Position, + position: Panel.Position, dataSource: string, - metric: Grafana.Metric, -): Grafana.Panel { + metric: Metric, +): Panel { return createTimeSeriesPanel( title, position, @@ -58,13 +58,13 @@ export function createTimeSeriesPercentagePanel( export function createTimeSeriesPanel( title: string, - position: Grafana.Panel.Position, + position: Panel.Position, dataSource: string, - metric: Grafana.Metric, + metric: Metric, unit?: string, min?: number, max?: number, -): Grafana.Panel { +): Panel { return { title, type: 'timeseries', @@ -96,10 +96,10 @@ export function createTimeSeriesPanel( export function createBurnRatePanel( title: string, - position: Grafana.Panel.Position, + position: Panel.Position, dataSource: string, - metric: Grafana.Metric, -): Grafana.Panel { + metric: Metric, +): Panel { return { type: 'stat', title, diff --git a/src/components/grafana/panels/index.ts b/src/components/grafana/panels/index.ts new file mode 100644 index 00000000..bbee81b7 --- /dev/null +++ b/src/components/grafana/panels/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './helpers'; +export * from './availability'; +export * from './success-rate'; +export * from './latency'; diff --git a/src/components/grafana/panels/latency.ts b/src/components/grafana/panels/latency.ts new file mode 100644 index 00000000..6c8a15c3 --- /dev/null +++ b/src/components/grafana/panels/latency.ts @@ -0,0 +1,107 @@ +import { queries as promQ } from '../../prometheus'; +import { Panel } from './types'; +import { + createStatPercentagePanel, + createTimeSeriesPanel, + createTimeSeriesPercentagePanel, + createBurnRatePanel, +} from './helpers'; + +export function createLatencyPanel(config: { + target: number; + window: promQ.TimeRange; + targetLatency: number; + filter: string; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createStatPercentagePanel( + 'Request % below 250ms', + { x: 16, y: 0, w: 8, h: 8 }, + config.dataSourceName, + { + label: 'Request % below 250ms', + query: promQ.getLatencyPercentageQuery( + config.ampNamespace, + config.window, + config.targetLatency, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createLatencyPercentilePanel(config: { + target: number; + shortWindow: promQ.TimeRange; + filter: string; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createTimeSeriesPanel( + '99th Percentile Latency', + { x: 12, y: 16, w: 12, h: 8 }, + config.dataSourceName, + { + label: '99th Percentile Latency', + query: promQ.getPercentileLatencyQuery( + config.ampNamespace, + config.shortWindow, + config.target, + config.filter, + ), + thresholds: [], + }, + 'ms', + ); +} + +export function createLatencyPercentagePanel(config: { + targetLatency: number; + shortWindow: promQ.TimeRange; + filter: string; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createTimeSeriesPercentagePanel( + 'Request percentage below 250ms', + { x: 0, y: 24, w: 12, h: 8 }, + config.dataSourceName, + { + label: 'Request percentage below 250ms', + query: promQ.getLatencyPercentageQuery( + config.ampNamespace, + config.shortWindow, + config.targetLatency, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createLatencyBurnRatePanel(config: { + target: number; + targetLatency: number; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createBurnRatePanel( + 'Latency Burn Rate', + { x: 16, y: 8, w: 8, h: 4 }, + config.dataSourceName, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getLatencyRateQuery( + config.ampNamespace, + '1h', + config.targetLatency, + ), + config.target, + ), + thresholds: [], + }, + ); +} diff --git a/src/components/grafana/panels/success-rate.ts b/src/components/grafana/panels/success-rate.ts new file mode 100644 index 00000000..1be06e87 --- /dev/null +++ b/src/components/grafana/panels/success-rate.ts @@ -0,0 +1,73 @@ +import { queries as promQ } from '../../prometheus'; +import { Panel } from './types'; +import { + createStatPercentagePanel, + createTimeSeriesPercentagePanel, + createBurnRatePanel, +} from './helpers'; + +export function createSuccessRatePanel(config: { + target: number; + window: promQ.TimeRange; + filter: string; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createStatPercentagePanel( + 'Success Rate', + { x: 8, y: 0, w: 8, h: 8 }, + config.dataSourceName, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + config.ampNamespace, + config.window, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createSuccessRateTimeSeriesPanel(config: { + shortWindow: promQ.TimeRange; + filter: string; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createTimeSeriesPercentagePanel( + 'HTTP Request Success Rate', + { x: 0, y: 16, w: 12, h: 8 }, + config.dataSourceName, + { + label: 'Success Rate', + query: promQ.getSuccessPercentageQuery( + config.ampNamespace, + config.shortWindow, + config.filter, + ), + thresholds: [], + }, + ); +} + +export function createSuccessRateBurnRatePanel(config: { + target: number; + filter: string; + ampNamespace: string; + dataSourceName: string; +}): Panel { + return createBurnRatePanel( + 'Success Rate Burn Rate', + { x: 8, y: 8, w: 8, h: 4 }, + config.dataSourceName, + { + label: 'Burn Rate', + query: promQ.getBurnRateQuery( + promQ.getSuccessRateQuery(config.ampNamespace, '1h', config.filter), + config.target, + ), + thresholds: [], + }, + ); +} diff --git a/src/components/grafana/panels/types.ts b/src/components/grafana/panels/types.ts new file mode 100644 index 00000000..889b8785 --- /dev/null +++ b/src/components/grafana/panels/types.ts @@ -0,0 +1,59 @@ +export type Panel = { + title: string; + gridPos: Panel.Position; + type: string; + datasource: string; + targets: { + expr: string; + legendFormat: string; + }[]; + fieldConfig: { + defaults: { + unit?: string; + min?: number; + max?: number; + color?: { + mode: string; + }; + thresholds?: { + mode: string; + steps: Threshold[]; + }; + custom?: { + lineInterpolation?: string; + spanNulls: boolean; + }; + }; + }; + options?: { + colorMode?: string; + graphMode?: string; + justifyMode?: string; + textMode?: string; + reduceOptions?: { + calcs?: string[]; + fields?: string; + values?: boolean; + }; + }; +}; + +export namespace Panel { + export type Position = { + x: number; + y: number; + w: number; + h: number; + }; +} + +export type Metric = { + label: string; + query: string; + thresholds: Threshold[]; +}; + +export type Threshold = { + value: number | null; + color: string; +};