Over-engineering the monitoring of my internet bandwidth usage

Over-engineering the monitoring of my internet bandwidth usage
the ich​or permeates all MY FACE MY FACE ᵒh god no NO NOO̼O​O NΘ stop the an​*̶͑̾̾​̅ͫ͏̙̤g͇̫͛͆̾ͫ̑͆l͖͉̗̩̳̟̍ͫͥͨe̠̅s ͎a̧͈͖r̽̾̈́͒͑e n​ot rè̑ͧ̌aͨl̘̝̙̃ͤ͂̾̆ ZA̡͊͠͝LGΌ ISͮ̂҉̯͈͕̹̘̱ TO͇̹̺ͅƝ̴ȳ̳ TH̘Ë͖́̉ ͠P̯͍̭O̚​N̐Y̡ H̸̡̪̯ͨ͊̽̅̾̎Ȩ̬̩̾͛ͪ̈́̀́͘ ̶̧̨̱̹̭̯ͧ̾ͬC̷̙̲̝͖ͭ̏ͥͮ͟Oͮ͏̮̪̝͍M̲̖͊̒ͪͩͬ̚̚͜Ȇ̴̟̟͙̞ͩ͌͝S̨̥̫͎̭ͯ̿̔̀ͅ

Background and the Initial Solution

Almost 9 years ago my monthly bandwidth cap on my home internet connection was 750GB up + down, a lot lower than it is now (in fact it's unlimited now, making all of this even more ridiculous). I can recall that it was as low as 400GB at one point as well. At that time the overage charges were pretty nasty too; $1-$5/GB or something.

Suffice it to say, I am a heavy bandwidth user and so are the people I live with so it became a ritual for me to start checking the bandwidth meter page to see if I needed to reign things in.

This is the bandwidth meter provided by my ISP

Doing this manually got old, and in October of 2015 I came up with a plan to automate collecting this information and sending alerts to my email using Google sheets and Google Apps Scripts.

The basic idea was:

  1. Fetch page source
  2. Parse bandwidth values
  3. Log this information to a sheet
  4. Run some alert logic (initially this was an e-mail to my G-mail inbox)

At this point in time I was already familiar with doing items 1, 3, and 4 using Google Apps Scripts and Google Sheets. So I spent some time experimenting to determine the "best" way to parse these values out of the HTML source code of the bandwidth page. There were 3 significant iterations and reading through my comments just now has been a source of great amusement.

Mk.I

/**
Creates a DOM tree to locate current months data underneath the first graph on the page.

Xml.parse is deprecated so..... Not for the faint of heart.
*/
function xmlDerps(html) {
  //@TODO: This can probably be redone more intelligently
  var doc = Xml.parse(html, true); //Mmmmm deprecation
  var bodyHtml = doc.html.body.toXmlString();
  doc = XmlService.parse(bodyHtml);  
  var root = doc.getRootElement();
  
  //This is kind of kludgy; the html doesn't give us much to distinguish the <p> tags with though
  //hopefully the current month <p> tag remains the third one on the page
  var currentMonthRoot = getElementsByTagName(root, 'p')[2]
  
  //Get all child tags of our <p> that are <font> tags
  var fontChilds = getElementsByTagName(currentMonthRoot, 'font')
  
  var up = 0
  var down = 0
  var tot = 0
  
  //Should be 6 font tags total
  for each (f in fontChilds) {
    var fl = parseFloat(f.getText()) //Cast value to a float for comparisons
    var cl = f.getAttribute('style') //Load font attribute 'style' for checking text color
    
    if(fl < 2000 && cl.getValue().indexOf("color: red") > -1) {
      up = fl
    }
    if(fl < 2000 && cl.getValue().indexOf("color: green") > -1) {
      down = fl
    }
    if(fl < 2000 && cl.getValue().indexOf("color: blue") > -1) {
      tot = fl
    }
  }
  return [up, down, tot]
}

This is somehow worse than using regex... good lord

Mk.II

/**
Uses regex and String.search() to pull current month's data out of the history table at the bottom
of the page.
*/
function regexDerps(html) {
  var months = ["\\January", "\\February", "\\March", "\\April", "\\May", "\\June", "\\July", "\\August", "\\September", "\\October", "\\November", "\\December"];
  
  //Locate first instance of CURRENT_MONTH, CURRENT_YEAR ex: "October, 2015"
  var dateString = months[(new Date()).getMonth()]  
  dateString += ', ' + (new Date()).getFullYear()
  var i = html.search(dateString)
  
  //Locate first instance of LAST_MONTH, CURRENT_YEAR ex: "September, 2015"
  dateString = months[(new Date()).getMonth()-1]
  dateString += ', ' + (new Date()).getFullYear()
  var j = html.search(dateString)
  
  if(j == -1) {
    j = html.length //If for whatever reason last month's data isn't in the table...
  }
  
  html = html.substring(i, j) //Slice out just the section with the data we want  
  
  /**
  Lucky for us PTD color codes up, down, and total as red, green, and blue respectively.
  We can use that to isolate the data we want.
  */
  var red = html.substring(html.search('color: red') + 12)       // +12 because "color: red'>" is 12 characters long
  var green = html.substring(html.search('color: green') + 14)   // +14 because "color: green'>" is 14 characters long
  var blue = html.substring(html.search('color: blue')+ 13)      // +13 because "color: blue'>" is 13 characters long
  
  //Our values are at the begining of these string now with a bunch of garbage after them
  //Substring from 0 to the next occurence of "<" to trim out what we want
  red = red.substring(0, red.search('<'))
  green = green.substring(0, green.search('<'))
  blue = blue.substring(0, blue.search('<'))
  
  return [parseFloat(red), parseFloat(green), parseFloat(blue)]
}

I had begun experimenting with regex at this point... this solution is truly half-baked

This approach was making use of a history table that appeared at the bottom of the page

Mk.III

/**
Used a proper regex to parse the html in ~10 lines.

This should be faster and more reliable than previous parsing methods as well as more resistant to page restructuring; as
long as the <h4> tag and the 3 <font> tags remain on the page in the same order of appearance this will not break.
*/
function regexDerpsv2(html) {
  html = html.replace(/\r?\n|\r/g,""); //remove newlines because I cbf to rewrite the pattern below
  var re = /<h4>This Month.+?<font style='color: red'>([-+]?[0-9]*\.?[0-9]+).+?<font style='color: green'>([-+]?[0-9]*\.?[0-9]+).+?<font style='color: blue'>([-+]?[0-9]*\.?[0-9]+)/;
  var m; 
  if ((m = re.exec(html)) !== null) { //Did we find a match?
      if (m.index === re.lastIndex) { //I don't know what this does lul...
          re.lastIndex++;
      }
      //upstream, downstream, and total values are defined in the regex as the 1st, 2nd, and 3rd capture groups
      return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3])]
  }
  return [-1, -1, -1]
}

By this iteration I had finally grokked enough regex concepts to do this somewhat elegantly...

I don't have any record of when I put this version of the code into use but I know that it ran without modification for at least 5 years, probably longer. We have the fact that the cable company's bandwidth utility hasn't been updated in at least that long to thank for that more than anything. You know what they say about parsing HTML using regular expressions... However, the HTML source of the page is quite minimal/simplistic and hasn't changed at all in almost 10 years as of this writing.

Disaster Strikes

Almost 8 years later, to the day, on October 13, 2023 the cable company made a policy change somewhere and it is no longer possible to access the bandwidth tool from outside of their network. This means my Google App Scripts code can no longer fetch the data on it's own.

By this point the monthly cap had already been raised to 6000GB/month and subsequently removed altogether, so I really wasn't in need of the alerts I had configured anymore. I could write some code to run on my server to pull the data and then somehow send it to the Google sheet (in fact, I am doing this now) but since there wasn't really an immediate need anymore I let it go stale for a few months...


Back in Black

I was working on a different project and realized how simple it is to configure a webhook you can send a POST request to in order to insert data into a Google Sheet and so I set out to revive the bandwidth tracker. I don't particularly need to worry about it anymore but it is fun data to log.

Within the code on the Google Sheet side of things I needed a doPost() function. Like so:

function doPost(e) {
  var postData = JSON.parse(JSON.parse(e.postData.contents));

  //Grab data from payload
  upstream = parseFloat([postData[0]]);
  downstream = parseFloat([postData[1]]);

  //pass it to legacy main function to process update
  update(upstream, downstream)

  return ContentService.createTextOutput("Data inserted into sheet successfully!").setMimeType(ContentService.MimeType.TEXT);
}

The main thing to understand here is that when this project is "deployed" we'll get a URL that we can send POST requests to and that this doPost() function is called when that happens.

"Manage Deployments" Dialogue within Apps Script
/**
Request bandwidth usage page from PTD
Parse upstream, downstream, and total values from html
Add row to spreadsheet
Send quota alerts if needed
*/
function update(upload, download) {
  //var url = 'https://cable_company_bandwidth_graph_utility'
  
  //MAKE SURE TO COMMENT LINE 12 AND UN-COMMENT LINE 13 WHEN TESTING!!
  //var html = UrlFetchApp.fetch(url).getContentText()
  //var html = getTestHtml()  
  
  //var parse = xmlDerps(html)    //This method no longer works, the deprecated methods it uses have finally been removed afaik
  //var parse = regexDerps(html)  //Garbage :)
  //var parse = regexDerpsv2(html)  //Fastest/Best. Regex is ugly, but when isn't regex ugly? :P
  
  var up = upload
  var down = download
  var tot = upload + download

This is the beginning of the main function in the sheet that used to run on a schedule. I've now commented out the code that used to fetch and parse the page and parameterized upload and download so that it can be called from within doPost() with the new values

The only missing piece now is code to run on my server to actually fetch/parse the data and send it on up to the sheet. I opted to do this in Python.

#!/usr/bin/env python3

from datetime import datetime
import json, re, requests

WEBHOOK_URL = "https://script.google.com/macros/s/WEBHOOK_ID/exec"
PTD_BANDWIDTH_GRAPH_URL = "https://cable_company_bandwidth_graph_utility"
PATTERN = r"<h4>This Month[\s\S]+?Total Upstream:[\s\S]+?GB \(<font style='color: red'>(?P<upload>\d+)[\s\S]+?Total Downstream: [\s\S]+?GB \(<font style='color: green'>(?P<download>\d+)"

def fetch_webpage(url):
    try:
        response = requests.get(url)
        
        if response.status_code == 200:
            return response.text
        else:
            return f"Error: {response.status_code} - Unable to fetch webpage"
    except Exception as e:
        return f"Error: {str(e)} - Unable to fetch webpage"

html = fetch_webpage(PTD_BANDWIDTH_GRAPH_URL) # fetch page
matches = re.finditer(PATTERN, html) # run regex

data = []
for match in matches:
    data = [int(match.group('upload')), int(match.group('download'))] # regex grabs number of bytes

json_payload = json.dumps([data[0]/1024/1024/1024, data[1]/1024/1024/1024]) # bytes -> GB, divide values by 1,073,741,824
print('payload:', json_payload)
response = requests.post(WEBHOOK_URL, json=json_payload) # send JSON payload to Google Sheet

if response.status_code == 200:
    print("POST request successful.", response.text)
else:
    print("POST request failed:", response.status_code)

I've got this added to my crontab running hourly

And there we go; the original functionality of the Google Sheet is restored by using my server at home as a middle man to communicate with the cable company and Google Sheets. But that's not where I left it...


Going off the rails a bit

While discussing this with a friend (also a customer of the same cable company) he mentioned he'd like to have this data available in Home Assistant. He suggested using a Home Assistant add-on called pyscript. This is a very cool add-on and he did eventually get this working but I chose to roll my own API using Flask. I prefererd this approach because it would allow other tools to make use of this data.

Now, I could just write a Flask app that fetches and parses the bandwidth meter page and returns the values every time the end point is hit... Actually, I did do that initially, so that I could figure out how to configure the RESTful sensor in Home Assistant.

It's been working quite well actually

Touching a bit on Flask, JSON, and Home Assistant


@app.route('/bandwidth-GB', methods=['GET'])
def get_bandwidth_usage_GB():
    attributes = {}
    row = magical_function_that_gets_the_data_we_want_to_serve()
    attributes["timestamp"] = row[0]
    attributes["upstream"] = int(row[1])/1024/1024/1024
    attributes["downstream"] = int(row[2])/1024/1024/1024
    attributes["total"] = int(row[3])/1024/1024/1024
    return jsonify(attributes)

@app.route('/')
def hello_world():
    return '<h1>API is alive</h2>'

if __name__ == '__main__':
   app.run(host="0.0.0.0", port=5000)

Some pseudo-code to demonstrate the basic concepts of Flask

There is much much more to Flask than anything I'm about to relay to you, but the long and short of it are those @app.route() function decorators that allow you to associate an HTTP endpoint with a function. If we run the code above an HTTP server will be fired up on port 5000

Ignoring the address and port in the screenshot we can see the result of hitting the root endpoint from the code above
This is what the JSON object looks like for the /bandwidth-GB endpoint I configured in the above pseudo-code

Now that the bare-minimum API is functioning lets look at the relevant Home Assistant configuration to create some sensors using this API as an example use case. The relevant documentation is here: Home Assistant - RESTful

rest:
  - scan_interval: 600
    resource: http://nalthis.c0smere.net:42070/bandwidth-GB
    sensor:
      - name: Internet Bandwidth Used
        value_template: "{{ value_json.total | round(3) }}"
        unit_of_measurement: GB
      - name: Internet Upstream Bandwidth Used
        value_template: "{{ value_json.upstream | round(3) }}"
        unit_of_measurement: GB
      - name: Internet Downstream Bandwidth Used
        value_template: "{{ value_json.downstream | round(3) }}"
        unit_of_measurement: GB

Relevant section added to my configuration.yml file to create three sensors using this new API

That's really just about it, the first time you add this to your config you do have to restart HA entirely for the sensors to be created.


Improving the API

I want to be respectful of the resource provided by the cable company, and by that I mean I want to make sure I'm not hammering their page with requests and being noticed. Initially, I modified the API script to store a timestamp of the last pull; if a request came in again before 60 minutes had elapsed the cached values from the last pull were returned. That would have worked just fine but I wanted more...

Circling back to the python script I'm running hourly via cron to insert data into the Google Sheet. I decided it was time to create a proper database and also store this information there. This will become the source of data that my API pulls from; it will always return the most recent data available and it doesn't matter how many requests I throw at it. The python script will add fresh data to it and the Google sheet hourly ensuring I'm not hitting the cable company's page more than once per hour.

docker run --name pgsql -e POSTGRES_PASSWORD=pa$$w0rd -d -v ${PWD}/db:/var/lib/postgresql/data -p 5432:5432 postgres

Spinning up a Postgres container is fairly straightforward

I installed pgAdmin and created a general purpose database 'gp0' and a 'SECV_SCRAPE' schema within to use specifically for this project. I also created a service account with usage rights on only this schema because that's just good practice. Finally I needed a base table for the python script to insert data into:

CREATE TABLE IF NOT EXISTS "SECV_SCRAPE".scrape_data
(
    time_stamp timestamp without time zone NOT NULL,
    upstream bigint NOT NULL,
    downstream bigint NOT NULL,
    total bigint NOT NULL,
    CONSTRAINT scrape_data_pkey PRIMARY KEY (time_stamp)
)

The base table that drives the whole show. In hindsight I should have made the time_stamp column TZ aware...

With the database spun up the next steps were:

  1. Modify hourly python script to also insert data into the new table
  2. Modify the API to fetch the data from the new database and not the cable company page

The additions to the hourly script are fairly simple:

#!/usr/bin/env python3

from datetime import datetime
import json, re, requests, psycopg2

WEBHOOK_URL = "https://script.google.com/macros/s/webhook_id/exec"
PTD_BANDWIDTH_GRAPH_URL = "https://cable_company_graph_util"
PATTERN = r"<h4>This Month[\s\S]+?Total Upstream:[\s\S]+?GB \(<font style='color: red'>(?P<upload>\d+)[\s\S]+?Total Downstream: [\s\S]+?GB \(<font style='color: green'>(?P<download>\d+)"

db_params = {
    'dbname': 'gp0',
    'user': 'user',
    'password': 'pa$$w0rd',
    'host': '10.0.0.1',
    'port': '5432'
}

insert_query = """
    INSERT INTO "SECV_SCRAPE".scrape_data (time_stamp, upstream, downstream, total)
    VALUES (%s, %s, %s, %s)
"""

def log_to_database(up, down):
    tot = up + down
    data = [datetime.now(), up, down, tot]

    # Connect to the database using a context manager
    with psycopg2.connect(**db_params) as conn:
        with conn.cursor() as cursor:
            cursor.execute(insert_query, data)
        conn.commit()

def fetch_webpage(url):
    try:
        response = requests.get(url)
        
        if response.status_code == 200:
            return response.text
        else:
            return f"Error: {response.status_code} - Unable to fetch webpage"
    except Exception as e:
        return f"Error: {str(e)} - Unable to fetch webpage"

html = fetch_webpage(PTD_BANDWIDTH_GRAPH_URL) # fetch page
matches = re.finditer(PATTERN, html) # run regex

data = []
for match in matches:
    data = [int(match.group('upload')), int(match.group('download'))] # regex grabs number of bytes

log_to_database(data[0], data[1])

json_payload = json.dumps([data[0]/1024/1024/1024, data[1]/1024/1024/1024]) # convert to GB to send up to Google sheet
print('payload:', json_payload)
response = requests.post(WEBHOOK_URL, json=json_payload) # send JSON payload to Google Sheet

if response.status_code == 200:
    print("POST request successful.", response.text)
else:
    print("POST request failed:", response.status_code)

This is the version that is running hourly on my server right now

The modifications to the API are similar

import psycopg2
from flask import Flask, jsonify
from datetime import datetime

db_params = {
    'dbname': 'gp0',
    'user': 'user',
    'password': 'pa$$w0rd',
    'host': '10.0.0.1',
    'port': '5432'
}

select_query = """
    SELECT * FROM "SECV_SCRAPE".scrape_data
    ORDER BY time_stamp DESC
    LIMIT 1
"""

@app.route('/bandwidth-GB', methods=['GET'])
def get_bandwidth_usage_GB():
    attributes = {}
    with psycopg2.connect(**db_params) as conn:
        with conn.cursor() as cursor:
            cursor.execute(select_query)
            rows = cursor.fetchall()
            for row in rows:
                attributes["timestamp"] = row[0]
                attributes["upstream"] = int(row[1])/1024/1024/1024
                attributes["downstream"] = int(row[2])/1024/1024/1024
                attributes["total"] = int(row[3])/1024/1024/1024
    return jsonify(attributes)

@app.route('/')
def hello_world():
    return '<h1>API is alive</h2>'

if __name__ == '__main__':
   app.run(host="0.0.0.0", port=5000)

Here I've replace magical_function_that_gets_the_data_we_want_to_serve() from the pseudo code above with some code to query the new database for the data to send back

With the API in a functional state I wanted to get it deployed to my server so I created a docker file so I could run it in a container.

# syntax=docker/dockerfile:1

FROM python:3.9.19-alpine3.19

WORKDIR /python-docker

COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt

COPY . .

CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"]

I also threw together a shell script to automatically stop and delete the running container (if there is one) and rebuild and start a new one to make pushing changes a lot less of a pain in the ass.

sudo docker rm $(docker stop $(docker ps -a -q --filter="name=magical_blackburn" --format="{{.ID}}"))
sudo docker build -t secv-api .
sudo docker run --name magical_blackburn -dp 42070:5000 secv-api

"magical_blackburn" was the auto-generated container name from the first time I ran this during testing and I decided to just go with it

What else can I do with this?

Adding a widget to my server dashboard

I use hompage for my server dashboard. There is support for Custom API widgets built in.

- SECV Internet Statistics:
    - Current Month Bandwidth:
        icon: mikrotik
        server: my-docker
        container: magical_blackburn
        widget:
          type: customapi
          url: http://192.168.88.5:42070/bandwidth-GB
          refreshInterval: 600000
          method: GET
          mappings:
            - field: upstream
              label: Up
              format: float
              suffix: GB
            - field: downstream
              label: Down
              format: float
              suffix: GB
            - field: total
              label: Total
              format: float
              suffix: GB

Relevant custom API widget configuration for my dashboard

The result is a pretty slick looking widget displaying my current usage for the month.

Calculating a bunch of useless statistics and creating additional API endpoints for them

One thing my sheet used to do was calculate how much bandwidth was used since the previous run.

The very first two rows from 2015 as an example

The first 4 columns are what's being supplied by the API at this point. Columns D, E, and F represent the change in each value since the previous pull. Anyone that's used a spreadsheet before can probably guess how I accomplished this in Google Sheets, but the data now resides in a relational database which is a lot more powerful but a different approach is required.

Taking a step back to think about what needs to happen; assuming the data is formatted by the time stamp column in descending order, to get the delta you just need to subtract the values from row (n-1) for any row n (excepting the very first one, and also rows where row(n-1) and row(n) occur in different months because the meter resets).

With that in mind, first it would be nice to have a "row number" column. This is very easy to do and you can even specify how you want the row numbers doled out i.e. "number the rows by time stamp in descending order"

SELECT row_number() OVER (ORDER BY scrape_data.time_stamp DESC) AS row_number,
            scrape_data.time_stamp,
            scrape_data.upstream,
            scrape_data.downstream,
            scrape_data.total
FROM "SECV_SCRAPE".scrape_data

The resulting data set looks like this:
Screenshot 2024-04-16 193334.png

So my approach is to take two copies of this table (including the new row numbers) and join them together on table_A.row = table_B.row-1
Here's a crappy visual I mocked up in Google Sheets to illustrate the concept:
Screenshot 2024-04-16 212952.png

In this new dataset you could subtract the "green" upstream value from the "blue" upstream value to figure out how much upstream bandwidth was used during that period and this is exactly what my scrape intervals view is doing.

Here is what the final view looks like, code below

period_start period_end duration u_diff d_diff t_diff u_rate d_rate t_rate
2024-04-16 20:00:07 2024-04-16 21:00:07 1:00:00 4528332190 51049516828 55577849018 1257868.968 14180409.11 15438278.07
2024-04-16 19:00:06 2024-04-16 20:00:07 1:00:01 4373371799 1564825002 5938196801 1214559.052 434578.2746 1649137.326
2024-04-16 18:00:06 2024-04-16 19:00:06 1:00:00 2549602746 762134538 3311737284 708236.7909 211708.1652 919944.9561
2024-04-16 17:00:07 2024-04-16 18:00:06 0:59:59 3119982283 1395273428 4515255711 866947.4305 387703.7122 1254651.143
2024-04-16 16:00:07 2024-04-16 17:00:07 1:00:00 280716922 442427423 723144345 77966.16802 122879.5562 200845.7242
WITH a
     AS (SELECT Row_number()
                  over (
                    ORDER BY scrape_data.time_stamp DESC) AS row_number,
                scrape_data.time_stamp,
                scrape_data.upstream,
                scrape_data.downstream,
                scrape_data.total
         FROM   "SECV_SCRAPE".scrape_data),
     b
     AS (SELECT Row_number()
                  over (
                    ORDER BY scrape_data.time_stamp DESC) AS row_number,
                scrape_data.time_stamp,
                scrape_data.upstream,
                scrape_data.downstream,
                scrape_data.total
         FROM   "SECV_SCRAPE".scrape_data)
SELECT b.time_stamp                                                         AS
       period_start,
       a.time_stamp                                                         AS
       period_end,
       a.time_stamp - b.time_stamp                                          AS
       duration,
       a.upstream - CASE
                      WHEN To_char(b.time_stamp, 'MM' :: text) <>
                           To_char(a.time_stamp, 'MM' :: text) THEN 0 :: bigint
                      ELSE b.upstream
                    END                                                     AS
       u_diff,
       a.downstream - CASE
                        WHEN To_char(b.time_stamp, 'MM' :: text) <>
                             To_char(a.time_stamp, 'MM' :: text) THEN 0 ::
                        bigint
                        ELSE b.downstream
                      END                                                   AS
       d_diff,
       a.total - CASE
                   WHEN To_char(b.time_stamp, 'MM' :: text) <>
                        To_char(a.time_stamp, 'MM' :: text) THEN 0 :: bigint
                   ELSE b.total
                 END                                                        AS
       t_diff,
       ( a.upstream - CASE
                        WHEN To_char(b.time_stamp, 'MM' :: text) <>
                             To_char(a.time_stamp, 'MM' :: text) THEN 0 ::
                        bigint
                        ELSE b.upstream
                      END ) :: NUMERIC / Extract(epoch FROM a.time_stamp -
                                         b.time_stamp)                      AS
       u_rate,
       ( a.downstream - CASE
                          WHEN To_char(b.time_stamp, 'MM' :: text) <>
                               To_char(a.time_stamp, 'MM' :: text) THEN 0 ::
                          bigint
                          ELSE b.downstream
                        END ) :: NUMERIC / Extract(epoch FROM a.time_stamp -
                                                              b.time_stamp) AS
       d_rate,
       ( a.total - CASE
                     WHEN To_char(b.time_stamp, 'MM' :: text) <>
                          To_char(a.time_stamp, 'MM' :: text) THEN 0 :: bigint
                     ELSE b.total
                   END ) :: NUMERIC / Extract(epoch FROM a.time_stamp -
                                                         b.time_stamp)
                                                             AS t_rate
FROM   a
       join b
         ON ( a.row_number + 1 ) = b.row_number; 

At the beginning you'll notice two CTEs A and B these are the two copies of the scrape_data table with a row number column added that are being joined together offset by 1 to generate the interval rows. All of the eyesore CASE statements are to handle the first pull of a new month when the counters reset.

What can I do with this?

I initially built this into the original spreadsheet so I could evaluate how much bandwidth I used for a given period; the old script ran every 12 hours so I'd calculate a percentage based on a hypothetical quota for 12 hours (ex. 4000GB / 730.5 hours in a month * 12 = 65.7GB).

I don't have a cap anymore so I thought it would be interesting to compute some usage statistics on a rolling 24 hour basis. I know my script is running hourly barring any errors or downtime so it would be "good enough" to just pull the most recent 24 rows from the view I detailed above each time a row is inserted into the main table and compute the statistics over that. Of course, downtime happens and it would be much cooler to be able to account for that. In simplest terms the code needs to be able to only aggregate as many rows from the view as it needs to add up to 24 hours; here's how I did it.

Similar to adding a row number to the data set when creating the view detailed above I added a cumulative sum of the duration column using a window function.

SELECT extract('epoch' FROM SUM(duration) OVER (ORDER BY period_start DESC)) AS x
       ,*
FROM "SECV_SCRAPE".scrape_data_intervals

x period_start period_end duration
3601 2024-04-16 21:00:07 2024-04-16 22:00:08 1:00:01
7201 2024-04-16 20:00:07 2024-04-16 21:00:07 1:00:00
10802 2024-04-16 19:00:06 2024-04-16 20:00:07 1:00:01
14402 2024-04-16 18:00:06 2024-04-16 19:00:06 1:00:00
18000 2024-04-16 17:00:07 2024-04-16 18:00:06 0:59:59

You can select rows for any arbitrary time period by filtering the x column to the desired duration in seconds making sure to leave some room for overshoot:

WITH cte as (
SELECT extract('epoch' FROM SUM(duration) OVER (ORDER BY period_start DESC)) AS x
      ,*
    FROM "SECV_SCRAPE".scrape_data_intervals
)
SELECT *
FROM cte
where x < 87300; -- 24.25 hours in seconds, to allow for some overshoot because of varying execution times

This query now returns whatever rows are required to represent the most recent 24 hour period when run so I added all of my statistical analysis to that and actually wrapped this code up into a trigger function that inserts these stats into a new table "SECV_SCRAPE".rolling_24hr_summary. This trigger function is called whenever a row is inserted into the main table by the python script.

This is what the data looks like in the new table

period_start period_end duration u_diff d_diff t_diff u_rate d_rate t_rate cap_bytes pct_cap_util
2024-04-15 22:00:07 2024-04-16 22:00:08 24:00:01 60798053410 95635839965 156433893375 703671.4936 1106881.069 1810552.563 214751319004 72.84%
2024-04-15 21:00:07 2024-04-16 21:00:07 24:00:00 67731653919 95809674441 163541328360 783930.7066 1108907.6 1892838.306 214748494387 76.15%
2024-04-15 20:00:07 2024-04-16 20:00:07 24:00:00 67026553967 47238579138 114265133105 775772.0194 546744.0255 1322516.045 214747888986 53.21%
2024-04-15 19:00:07 2024-04-16 19:00:06 24:00:00 63720461365 47514693228 111235154593 737509.0211 549941.324 1287450.345 214747292907 51.80%
2024-04-15 18:00:07 2024-04-16 18:00:06 23:59:59 61685365375 51139735624 112825100999 713956.2335 591899.4368 1305855.67 214746787078 52.54%
BEGIN
WITH cte AS (
    SELECT *
          ,extract('epoch' FROM SUM(duration) OVER (ORDER BY period_start DESC)) AS x
    FROM "SECV_SCRAPE".scrape_data_intervals
    )
INSERT INTO "SECV_SCRAPE".rolling_24hr_summary
SELECT MIN(period_start) AS period_start
      ,MAX(period_end) AS period_end
      ,SUM(duration) AS duration
      ,SUM(u_diff) AS u_diff
      ,SUM(d_diff) AS d_diff
      ,SUM(t_diff) AS t_diff
      ,SUM(u_diff)/extract('epoch' FROM SUM(duration)) AS u_rate
      ,SUM(d_diff)/extract('epoch' FROM SUM(duration)) AS d_rate
      ,SUM(t_diff)/extract('epoch' FROM SUM(duration)) AS t_rate
	  ,(((((MAX(cap.cap_bytes)/extract(days FROM date_trunc('month', MIN(period_start)) + interval '1 month - 1 day'))/24)/60)/60) * extract('epoch' FROM SUM(duration))) AS cap_bytes
	  ,SUM(t_diff) / (((((MAX(cap.cap_bytes)/extract(days FROM date_trunc('month', MIN(period_start)) + interval '1 month - 1 day'))/24)/60)/60) * extract('epoch' FROM SUM(duration))) AS pct_cap_util
FROM cte
LEFT JOIN "SECV_SCRAPE".bandwidth_cap_history cap
	ON period_start BETWEEN cap.start_date and cap.end_date
WHERE x < 87300; -- 24.25 hours in seconds, to allow for some overshoot because of varying execution times
RETURN NEW;
END

Trigger function code to add statistics to the rolling_24hr_summary table

Creating an endpoint for this data
SELECT period_start
      ,period_end
      ,u_diff/1024.0/1024/1024 AS u_diff_GB
      ,d_diff/1024.0/1024/1024 AS d_diff_GB
      ,t_diff/1024.0/1024/1024 AS t_diff_GB
      ,u_rate/1024.0/1024 AS u_rate_MBs
      ,d_rate/1024.0/1024 AS d_rate_MBs
      ,t_rate/1024.0/1024 AS t_rate_MBs
      ,cap_bytes/1024.0/1024/1024 AS cap_GB
      ,pct_cap_util*100 AS pct_cap_util
FROM "SECV_SCRAPE".rolling_24hr_summary
ORDER BY period_end DESC
LIMIT 1

A query to grab the most recent rolling 24 hour stats in a human readable format for the new API endpoint

def generic_query(q):
    attributes = {}
    with psycopg2.connect(**db_params) as conn:
        with conn.cursor() as cursor:
            cursor.execute(q)
            rows = cursor.fetchall()
            col_names = [desc[0] for desc in cursor.description]
            
            for row in rows:
                for col_name, value in zip(col_names, row):
                    attributes[col_name] = value
                    
    return jsonify(attributes)

@app.route('/rolling24', methods=['GET'])
def get_rolling_24_data():
    return generic_query(rolling_query)

By this point I abstracted the query running a little bit; the "generic_query" function returns a dictionary where the keys are the column names from the query result set.

Adding this data to my dashboard
    - Rolling 24hr Statistics:
        icon: mikrotik
        server: my-docker
        container: magical_blackburn
        widget:
          type: customapi
          url: http://192.168.88.5:42070/rolling24
          refreshInterval: 600000
          method: GET
          display: list
          mappings:
            - field: period_start
              label: Report Period
              format: date
              locale: en-GB
              timeStyle: short
              additionalField:
                field: period_end
                label: end
                format: date
                locale: en-GB
                timeStyle: short
            - field: u_rate_mbs
              label: Upload/Download Average Rate
              format: float
              suffix: MB/s
              additionalField:
                field: d_rate_mbs
                label: Download
                format: float
                suffix: MB/s
            - field: t_rate_mbs
              label: Combined
              format: float
              suffix: MB/s
            - field: u_diff_gb
              label: Upstream/Downstream Bandwidth
              format: float
              suffix: GB
              additionalField:
                field: d_diff_gb
                label: Downstream
                format: float
                suffix: GB
            - field: t_diff_gb
              label: Combined
              format: float
              suffix: GB
            - field: pct_cap_util
              label: Capacity 
              format: percent

Relevant homepage services.conf configuration

The finished product

Wrapping Up

If you've made it this far seek professional help.

Anyway, this (actually writing all of this up) was an interesting exercise. I mostly just wanted to check out Ghost and I added this blog to my resume so I felt compelled to write at least one post. I've gained a lot of appreciation/respect for the authors of the thousands of blog posts I've consumed up until this point in my life that have taught me so much, saved me time and sanity, and entertained in general.

If nothing else I'll probably consult this post in the future when something inevitably breaks to remind myself what in the hell I was thinking when I wrote this.

Wes

Lightning Round

I did create another view where each row is a monthly total. I'm not going to go into gory details; I followed a similar pattern of using a window function to add a row number to the main data set (this time by time stamp descending partitioned by YYYY-MM of the time stamp. This ensures that the final record for any given month will have a row number of 1) and summarizing. I used this to create an "all time" endpoint which looks like this on my dashboard:

That a lotta data (Screenshot taken 2024-04-16)

Thanks for reading.