ThinkMassive.org

Postgres Dashboard to Grafonnet

February 25, 2023

Update: While writing this out I realized the process really needed to be scripted to maintain sanity. Soon I discovered Grafana is actively building a tool to convert Grafana JSON into Grafonnet. I decided to post these unedited notes, in case they’re helpful in the meantime.

Converting a Grafana dashboard to use grafonnet

Grafana dashboards are extremely useful, although maintaining changes can be a pain when dealing only with JSON blobs. In this article I explain how I converted the Postgres Overview dashboard to use grafonnet-lib.

First we must clone the git repo and change to the postgres_mixin directory:

$ git clone https://github.com/prometheus-community/postgres_exporter
$ cd postgres_exporter/postgres_mixin

Next we create a new file postgres-overview.jsonnet for our grafonnet dashboard:

$ echo <<EOF > postgres-overview.jsonnet

local grafana = import 'grafonnet/grafana.libsonnet';
local dashboard = grafana.dashboard;
local template = grafana.template;
local row = grafana.row;
local panel = grafana.panel;
local prometheus = grafana.prometheus;
local graphPanel = grafana.graphPanel;

grafana.dashboard.new(
  title='Postgres Overview',
)
EOF

Now we’re ready to start populating attributes from the legacy dashboard. We begin by listing top-level dashboard attributes:

$ jq '. | del(.targets, .templating, .panels, .timepicker)' \
  dashboards/postgres-overview.json
{
  "annotations": {
    "list": [
      {
        "builtIn": 1,
        "datasource": "-- Grafana --",
        "enable": true,
        "hide": true,
        "iconColor": "rgba(0, 211, 255, 1)",
        "name": "Annotations & Alerts",
        "type": "dashboard"
      }
    ]
  },
  "description": "Performance metrics for Postgres",
  "editable": true,
  "gnetId": 455,
  "graphTooltip": 0,
  "id": 1,
  "iteration": 1603191461722,
  "links": [],
  "refresh": false,
  "schemaVersion": 26,
  "style": "dark",
  "tags": [
    "postgres"
  ],
  "time": {
    "from": "now-1h",
    "to": "now"
  },
  "timezone": "browser",
  "title": "Postgres Overview",
  "uid": "wGgaPlciz",
  "version": 5
}

Referring to dashboard.libsonnet, we can set all top-level attributes with non-default values:

grafana.dashboard.new(
  title='Postgres Overview',
  tags=['postgres'],
  time_from='now-1h',
  description='Performance metrics for Postgres',
)

Next we will add templating (variables), panels, and targets.

Templating

The template library is used to add datasources and template variables.

Template source variable

First we view the datasource attributes:

$ jq '.templating.list[] | select(.type=="datasource")' \
  dashboards/postgres-overview.json
{
  "hide": 0,
  "includeAll": false,
  "label": "Data Source",
  "multi": false,
  "name": "datasource",
  "options": [],
  "query": "prometheus",
  "refresh": 1,
  "regex": "",
  "skipUrlSync": false,
  "type": "datasource"
}

To add this using grafonnet, we use addTemplate() and pass in a template.datasource object. This function is appended to the dashboard.new object we started earlier. Other than the mandatory attributes (w/o a default listed), only non-default values need to be specified.

grafana.dashboard.new(
  title='Postgres Overview',
  tags=['postgres'],
  time_from='now-1h',
  description='Performance metrics for Postgres',
)
.addTemplate(
  template.datasource(
    name='datasource',
    query='prometheus',
    current='Prometheus',
    label='Data Source',
  )
)

Generating the dashboard now should succeed, but it will be empty. First we need to update the dashboard library to use this new file, and we also need to install the grafonnet library using jsonnet-bundler:

$ sed -i \
  "s/import 'postgres-overview.json'/import 'postgres-overview.jsonnet'/" \
  dashboards/dashboards.libsonnet

$ jb init
$ jb install https://github.com/grafana/grafonnet-lib/grafonnet

Then we can run mixtool to build the new dashboard:

$ mixtool generate dashboards mixin.libsonnet --directory dashboards_out

This should succeed (with no output) and you can view the resulting dashboards_out/postgres-overview.json in a text editor and/or load it into Grafana.

Template variables

To view the remaining template variables we repeat the same jq query as before, but change the == to !=:

jq '.templating.list[] | select(.type!="datasource")' \
  dashboards/postgres-overview.json                      
{
  "allValue": ".+",
  "datasource": "$datasource",
  "definition": "label_values(pg_up, job)",
  "hide": 0,
  "includeAll": true,
  "label": "job",
  "multi": true,
  "name": "job",
  "options": [],
  "query": "label_values(pg_up, job)",
  "refresh": 0,
  "regex": "",
  "skipUrlSync": false,
  "sort": 0,
  "tagValuesQuery": "",
  "tags": [],
  "tagsQuery": "",
  "type": "query",
  "useTags": false
}
{
  "allValue": ".+",
  "datasource": "$datasource",
  "definition": "",
  "hide": 0,
  "includeAll": true,
  "label": "instance",
  "multi": true,
  "name": "instance",
  "options": [],
  "query": "label_values(up{job=~\"$job\"},instance)",
  "refresh": 1,
  "regex": "",
  "skipUrlSync": false,
  "sort": 0,
  "tagValuesQuery": "",
  "tags": [],
  "tagsQuery": "",
  "type": "query",
  "useTags": false
}
{
  "allValue": ".+",
  "datasource": "$datasource",
  "definition":
"label_values(pg_stat_database_tup_fetched{instance=~\"$instance\",datname!~\"template.*|postgres\"},datname)",
  "hide": 0,
  "includeAll": true,
  "label": "db",
  "multi": false,
  "name": "db",
  "options": [],
  "query":
"label_values(pg_stat_database_tup_fetched{instance=~\"$instance\",datname!~\"template.*|postgres\"},datname)",
  "refresh": 1,
  "regex": "",
  "skipUrlSync": false,
  "sort": 0,
  "tagValuesQuery": "",
  "tags": [],
  "tagsQuery": "",
  "type": "query",
  "useTags": false
}

This may look like a lot, although all are type: query and most values are defaults. We define these using template.new(), passed to addTemplate(), which are appended to the previous addTemplate() that we used for the datasource.

.addTemplate(
  template.datasource(
    name='datasource',
    query='prometheus',
    current='Prometheus',
    label='Data Source',
  )
)
.addTemplate(
  template.new(
    name='job',
    datasource='$datasource',
    query='label_values(pg_up, job)',
    label='job',
    allValues='.+',
    includeAll=true,
    multi=true,
  )
)
.addTemplate(
  template.new(
    name='instance',
    datasource='$datasource',
    query='label_values(up{job=~"$job"},instance)',
    label='instance',
    allValues='.+',
    refresh='load',
    includeAll=true,
    multi=true,
  )
)
.addTemplate(
  template.new(
    name='db',
    datasource='$datasource',
    query='label_values(pg_stat_database_tup_fetched{instance=~\"$instance\",datname!~\"template.*|postgres\"},datname)',
    label='db',
    allValues='.+',
    refresh='load',
    includeAll=true,
  )
)

To validate our jsonnet, run mixtool again:

mixtool generate dashboards mixin.libsonnet --directory dashboards_out

Panels

With templating complete, we’re ready to add the panels from our JSON dashboard. Many dashboards use rows to organize panels, which also adds the ability to collapse them. This dashboard does not, so they aren’t covered here. The concept is the same as adding the panels, but using row instead.

From looking at our dashboard we see six panels. Each contains many attributes, so we start by listing the title and type of each panel:

$ jq '.panels | keys[] as $k | "\(.[$k].title), \(.[$k].type)"' \
  dashboards/postgres-overview.json

"Rows, graph"
"QPS, singlestat"
"Buffers, graph"
"Conflicts/Deadlocks, graph"
"Cache hit ratio, graph"
"Number of active connections, graph"

One is singlestat, the other five are graph. Each will be added by passing either graph_panel or stat_panel to the addPanel(), function. We will look at the first two panels in detail (last 4 follow the same process as the first).

Rows Graph Panel

Display the complete Rows panel JSON, except targets:

$ jq '.panels[] | select(.title=="Rows") | del(.targets)' \
  dashboards/postgres-overview.json
{
  "aliasColors": {},
  "bars": false,
  "dashLength": 10,
  "dashes": false,
  "datasource": "$datasource",
  "editable": true,
  "error": false,
  "fieldConfig": {
    "defaults": {
      "custom": {}
    },
    "overrides": []
  },
  "fill": 1,
  "fillGradient": 0,
  "grid": {},
  "gridPos": {
    "h": 7,
    "w": 20,
    "x": 0,
    "y": 0
  },
  "hiddenSeries": false,
  "id": 1,
  "isNew": true,
  "legend": {
    "alignAsTable": true,
    "avg": true,
    "current": false,
    "max": true,
    "min": true,
    "rightSide": true,
    "show": true,
    "total": false,
    "values": true
  },
  "lines": true,
  "linewidth": 1,
  "links": [],
  "nullPointMode": "connected",
  "options": {
    "alertThreshold": true
  },
  "percentage": false,
  "pluginVersion": "7.2.1",
  "pointradius": 5,
  "points": false,
  "renderer": "flot",
  "seriesOverrides": [],
  "spaceLength": 10,
  "stack": false,
  "steppedLine": false,
  "thresholds": [],
  "timeFrom": null,
  "timeRegions": [],
  "timeShift": null,
  "title": "Rows",
  "tooltip": {
    "msResolution": true,
    "shared": true,
    "sort": 0,
    "value_type": "cumulative"
  },
  "type": "graph",
  "xaxis": {
    "buckets": null,
    "mode": "time",
    "name": null,
    "show": true,
    "values": []
  },
  "yaxes": [
    {
      "format": "short",
      "label": null,
      "logBase": 1,
      "max": null,
      "min": null,
      "show": true
    },
    {
      "format": "short",
      "label": null,
      "logBase": 1,
      "max": null,
      "min": null,
      "show": true
    }
  ],
  "yaxis": {
    "align": false,
    "alignLevel": null
  }
}

Start appending the contents using addPanel(graphPanel.new()). Since jq sorts the keys, making them easy to locate, I traverse the list in graph_panel.libsonnet. For each item, compare the value from the JSON dashboard, and add any that are required or non-default.

The JSON dashboard has some nested objects, such as the axes and grid position. The list in graph_panel is a flat representation, so on the first pass you can skip items that say “_ of the _ axis” or similar. Add those on a second pass, and add gridPos at the end.

.addTemplate(
  template.new(
    name='db',
    ...
  )
)
.addPanel(
  graphPanel.new(
    title="Rows",
    datasource="$datasource",
  ), gridPos={h=7, w=20, x=0, y=0}
)

To complete this panel, we need to add targets that contain the queries for each panel. First let’s list the datasource type and expression for each:

$ jq '.panels[] | select(.title=="Rows") | .targets[] | .dsType, .expr' \
  dashboards/postgres-overview.json

"prometheus"
"sum(irate(pg_stat_database_tup_fetched{datname=~\"$db\",job=~\"$job\",instance=~\"$instance\"}[$__rate_interval]))"
"prometheus"
"sum(irate(pg_stat_database_tup_returned{datname=~\"$db\",job=~\"$job\",instance=~\"$instance\"}[$__rate_interval]))"
"prometheus"
"sum(irate(pg_stat_database_tup_inserted{datname=~\"$db\",job=~\"$job\",instance=~\"$instance\"}[$__rate_interval]))"
"prometheus"
"sum(irate(pg_stat_database_tup_updated{datname=~\"$db\",job=~\"$job\",instance=~\"$instance\"}[$__rate_interval]))"
"prometheus"
"sum(irate(pg_stat_database_tup_deleted{datname=~\"$db\",job=~\"$job\",instance=~\"$instance\"}[$__rate_interval]))"

In this case all are of type prometheus, so we use [prometheus.target])(https://github.com/grafana/grafonnet-lib/blob/master/grafonnet/prometheus.libsonnet).

Each target can be inspected individually for non-default values, by specifying the array index in targets[]. For example, element 0:

$ jq '.panels[] | select(.title=="Rows") | .targets[0] | .dsType, .expr' \
  dashboards/postgres-overview.json
{
  "alias": "fetched",
  "dsType": "prometheus",
  "expr": "sum(irate(pg_stat_database_tup_fetched{datname=~\"$db\",job=~\"$job\",instance=~\"$instance\"}[$__rate_interval]))",
  "format": "time_series",
  "groupBy": [
    {
      "params": [
        "$interval"
      ],
      "type": "time"
    },
    {
      "params": [
        "null"
      ],
      "type": "fill"
    }
  ],
  "intervalFactor": 2,
  "legendFormat": "fetched",
  "measurement": "postgresql",
  "policy": "default",
  "refId": "A",
  "resultFormat": "time_series",
  "select": [
    [
      {
        "params": [
          "tup_fetched"
        ],
        "type": "field"
      },
      {
        "params": [],
        "type": "mean"
      },
      {
        "params": [
          "10s"
        ],
        "type": "non_negative_derivative"
      }
    ]
  ],
  "step": 120,
  "tags": [
    {
      "key": "instance",
      "operator": "=~",
      "value": "/^$instance$/"
    }
  ]
}

Use the addTarget() function with a prometheus.new() parameter for each.

These are appended to the graphPanel.new() object, within the top-level addPanel() object. Be sure to keep gridPos in the expected location, as the second argument to addPanel(), as indicated below.

.addTemplate(
  template.new(
    name='db',
    ...
  )
)
.addPanel(
  graphPanel.new(
    title="Rows",
    datasource="$datasource",
  )
  .addTarget(
    prometheus.target(
      expr='sum(irate(pg_stat_database_tup_fetched{datname=~"$db",job=~"$job",instance=~"$instance"}[$__rate_interval]))',
    )
  )
  .addTarget(
    prometheus.target(
      expr='sum(irate(pg_stat_database_tup_returned{datname=~"$db",job=~"$job",instance=~"$instance"}[$__rate_interval]))',
    )
  )
  .addTarget(
    prometheus.target(
      expr='sum(irate(pg_stat_database_tup_inserted{datname=~"$db",job=~"$job",instance=~"$instance"}[$__rate_interval]))',
    )
  )
  .addTarget(
    prometheus.target(
      expr='sum(irate(pg_stat_database_tup_updated{datname=~"$db",job=~"$job",instance=~"$instance"}[$__rate_interval]))',
    )
  )
  .addTarget(
    prometheus.target(
      expr='sum(irate(pg_stat_database_tup_deleted{datname=~"$db",job=~"$job",instance=~"$instance"}[$__rate_interval]))',
    )
  ), gridPos={h: 7, w: 20, x: 0, y: 0}
)

To validate our jsonnet, run mixtool again:

mixtool generate dashboards mixin.libsonnet --directory dashboards_out

QPS Stat Panel

Work in progress, check back soon.

Remaining Graph Panels

Repeat the process used for adding the Rows panel for each remaining graph panel:


Building the dashboard

mixtool generate dashboards mixin.libsonnet --directory dashboards_out

mixtool lint --grafana dashboards/dashboards.libsonnet

mixtool mixin.libsonnet

Comparing grafonnet output to the original

# Run from the postgres_mixin directory
cd ..

vimdiff \
  <(jq . dashboards/postgres-overview.json) \
  <(jq . dashboards_out/postgres-overview.json)