, ,

Infrastructure Telemetry as a Dataset for Data Science (Infra to Data Science Series, Part 7)

Your monitoring data already is a dataset. This part reframes infrastructure telemetry as rows and columns, and shows why a counter has to become a rate before it can be a feature.

Infra to Data Science Series · Part 7 of 26

A Grafana panel showing a month of CPU at a 30 second scrape is not a picture, it is 86,400 rows waiting to become a dataset, and at a 15 second scrape it is 172,800. Once you read the panel as a table instead of a graph, the question stops being what does this line say and becomes what does one row mean, which is the first question a data scientist asks of anything.

Key takeaways: Your telemetry already is a dataset, the reframe is rows and columns, not panels and alerts. Metric type decides the column, a gauge is a feature as it stands while a counter must be differenced into a rate first or it just encodes elapsed time. Tidy shape means one observation per row and one metric per column. Sampling interval and label cardinality set the size of the table before any model exists, one metric at 15 seconds for 30 days is 172,800 rows per series, and across 50 nodes that is 8.6 million. A missing scrape is a null, not a zero, and filling a gauge gap with 0 dropped an average from 38.8 to 29.85 in the run below.
Who this is for: An infrastructure engineer, SRE or platform admin who finished Part 6 with a pinned, reproducible notebook and can export a CSV from Prometheus, Grafana, CloudWatch or vCenter. Terms on first use: telemetry is the stream of measurements your systems emit; a gauge is a metric that moves up and down such as CPU percent or memory used; a counter only rises until it resets, such as bytes sent or requests served; cardinality is the count of distinct label combinations; a scrape is one collection of a metric at one timestamp.

Two views of the same numbers, dashboard and dataset

You have stared at telemetry for years, but as dashboards and alert thresholds, not as observations and features. A data scientist looks at the identical bytes and sees a table: each collection is a row, each metric is a column, each label is a category, and the timestamp is what orders it all. Nothing about the data changes, only the frame you put around it. Getting fluent in both frames at once is the actual skill this part builds, and the map below is the artifact to keep, because every later part in this series reshapes telemetry using exactly these correspondences.

Monitoring conceptDataset conceptWhat you do with it
One scrape at time tone observation, a rowkeep the timestamp as the index
A metric name like cpu_pcta feature, a columnone column per metric after reshaping
A gauge valuea numeric feature used directlymodel the value as it is
A counter valuenot a feature yetdifference it into a rate first
A label like node or joba categorical columnone row per label set per timestamp
Scrape intervalthe row spacingresample onto a regular grid
A missing scrapea null, NaNreindex, then choose fill or drop

Here is the reframe made literal. A single metric from one entity, sampled for 30 days, is a different number of rows depending only on the scrape interval you exported at, and that number is your table before you have modelled a thing. Read the chart as a warning as much as a fact, because the leftmost bar is a size most laptops handle fine for one series and choke on once you multiply by every node.

Rows from one metric, one entity, 30 daysrow count set only by the scrape interval you export at15 sec172,80030 sec86,40060 sec43,2005 min8,640Halving the interval doubles the rows, and you still have to multiply by every node
One metric, one entity, 30 days, at four common scrape intervals. This is the running project decision, pick the interval and the entity before you export, or the table picks a size for you.

Last part we made the notebook reproducible, this part we look hard at the raw material and decide what a row is before cleaning or modelling anything. For the running project, export a month of one cluster CPU and memory at a 5 minute interval, which lands near 8,640 rows per metric and stays comfortable in memory. If you want a public stand in to follow along, the Numenta Anomaly Benchmark ships a real AWS CloudWatch export of cluster CPU that reads exactly like your own, and I point to it in the references. For the loading mechanics, the Data Science Series has a full part on getting data into Python, so here I spend the effort on what the numbers mean rather than on read_csv arguments.

One observation per row, tidy telemetry

Tidy data has a precise meaning, from Hadley Wickham: each variable is a column, each observation is a row, each kind of observational unit is its own table. Telemetry rarely arrives tidy. It arrives long, one row per metric per timestamp, or wide with a column per node, or as JSON with labels nested inside. Your first job is to reshape it into one row per timestamp with a clean column per metric, and the first pothole is not the shape at all, it is the timestamp coming back as text.

# tested on Python 3.10.12, pandas 2.3.3, numpy 2.2.6
import pandas as pd

df = pd.read_csv('cpu.csv')          # a Grafana or CloudWatch export
print(df.dtypes)

df.set_index('timestamp').resample('10min').mean()   # try to bucket it
timestamp     object      # the trap, a string, not a time
cpu_pct      float64

TypeError: Only valid with DatetimeIndex, TimedeltaIndex or PeriodIndex,
but got an instance of 'Index'

That object dtype is the same class of bug the last part warned about, a column that looks right and behaves wrong. pandas read the timestamp as an ordinary string, so resample has no time axis to work with and refuses. Parse the dates on load and the same call succeeds, bucketing the 5 minute rows into 10 minute means.

df = pd.read_csv('cpu.csv', parse_dates=['timestamp'])
print(df.dtypes)
print(df.set_index('timestamp').resample('10min').mean())
timestamp    datetime64[ns]
cpu_pct             float64

                     cpu_pct
timestamp
2026-06-01 00:00:00    42.10
2026-06-01 00:10:00    46.55

With a real time index, reshaping long telemetry to tidy wide is one call. Below is the shape every later part assumes, raw export to long frame to wide grid to a labelled table you can model.

flowchart LR
  A[Raw export, long or JSON] --> B[Parse timestamp to datetime]
  B --> C[Pivot to one row per timestamp]
  C --> D[Resample onto a regular grid]
  D --> E[Difference counters into rates]
  E --> F[Tidy dataset, features and label]
The reshape every later part reuses. Two steps in the middle, pivot and resample, do most of the work, and the counter step after them is the one most people skip.
# long: one row per metric per timestamp, the common monitoring export
wide = long.pivot(index='timestamp', columns='metric', values='value')
print(wide)

# if the same timestamp appears twice, pivot cannot reshape, aggregate instead
dup.pivot(index='timestamp', columns='metric', values='value')
metric               cpu_pct  mem_pct
timestamp
2026-06-01 00:00:00     22.4     68.0
2026-06-01 00:05:00     61.8     70.2

ValueError: Index contains duplicate entries, cannot reshape
# fix: dup.pivot_table(..., aggfunc='mean') collapses the duplicate scrape

Duplicate timestamps are common when two collectors overlap or a scrape is retried, and pivot draws a hard line there rather than guess. pivot_table with an aggregation collapses the pair, which is what you want as long as you noticed the duplicate first. Reshaping and vectorised pandas get their own treatment in the Data Science Series part on NumPy and pandas, worth a read once your frames outgrow a few thousand rows.

Counters versus gauges, and why the type sets the column

Here is the place the tutorial default is wrong for infrastructure data. Usual advice is to point pandas at your metrics and start modelling, and that works fine for CPU percent or memory used, which are gauges that move up and down and mean what they say. It quietly ruins counters. Prometheus defines a counter as a cumulative value that only increases until a restart resets it to zero, so a counter fed straight in as a feature encodes how long the process has been running, not how busy it is. You must difference it into a rate first, exactly as PromQL does with its rate function under the hood.

import pandas as pd, numpy as np

idx = pd.date_range('2026-06-01', periods=8, freq='5min')
# net_bytes_total is a COUNTER, it climbs until a restart resets it
ctr = pd.Series([100, 250, 500, 900, 1400, 50, 300, 700], index=idx)

delta = ctr.diff()                       # step to step change
print(delta.tolist())

delta[delta < 0] = np.nan                # a restart is unknown, not a real drop
secs = ctr.index.to_series().diff().dt.total_seconds()
print((delta / secs).round(3).tolist())  # bytes per second, a usable feature
[nan, 150.0, 250.0, 400.0, 500.0, -1350.0, 250.0, 400.0]
                                    # the restart shows up as a negative
[nan, 0.5, 0.833, 1.333, 1.667, nan, 0.833, 1.333]

The raw counter climbs from 100 to 1400, then a restart drops it to 50, and the naive difference reports a change of minus 1350 that never physically happened. Clamping negative steps to null and dividing by the seconds between scrapes turns the mess into a clean per second rate, the feature you actually wanted. Applying rate to a counter yields a gauge, which is precisely why the result is now safe to model. Miss this step and every counter in your export becomes a slow ramp that correlates with time itself, and time is the one thing you never want a model leaning on.

Verdict: Before any metric becomes a column, label it a gauge or a counter and treat it accordingly. Gauges go in as they stand, CPU percent, memory, temperature, queue depth. Counters get differenced into a rate first, requests, bytes, errors, restarts. My pick for a shared pipeline is to convert counters to rates at ingest and store the rate, so no downstream notebook can forget. The one to avoid is feeding a raw counter into a model, because it will hand you a high score built on elapsed time and fall apart in production.

Sampling interval and label cardinality as table size

Two dials set how big your dataset gets, and both are decisions you make before exporting, not after. One dial is the sampling interval from the chart earlier, where 15 seconds gives 172,800 rows per series over 30 days and 5 minutes gives 8,640. Cardinality is the other dial, the number of distinct label combinations, and it multiplies. One CPU metric across 50 nodes is 50 series, so at 15 seconds that is 8.6 million rows. Add a second label such as mount point on a disk metric, say 6 mounts per node, and 50 times 6 series at 15 seconds reaches 51.8 million rows for a single metric name.

This is where a naive export detonates, and the fix runs against instinct. An operator wants every node, every mount, every second, because in monitoring more resolution is almost always better. For a first dataset the opposite is true. Pick one metric, one entity, and the coarsest interval that still shows the behaviour you care about, then widen only once a model earns it. The Numenta benchmark makes the same choice for you, aggregating its taxi series into 30 minute buckets and its tweet series into 5 minute counts, because a clean single series teaches more than a raw firehose. Time aware resampling and the backtesting that goes with it is the Data Science Series topic in time series forecasting, and you will lean on it from Part 21 onward.

Missing scrapes, gaps and honest nulls

Real telemetry has holes. A target goes down, a scrape times out, a collector restarts, and the exported CSV simply has no row for those timestamps. If you never line the data up against the interval it should have, those gaps hide, and worse, a naive fill turns them into fiction. Reindex onto the full expected grid first, so every missing scrape becomes an explicit null you can see and count.

full = pd.date_range('2026-06-01 00:00', '2026-06-01 01:00', freq='5min')  # 13 scrapes
reg = frame.reindex(full)                 # line up on the grid that should exist
print('missing scrapes:', reg['cpu_pct'].isna().sum())

# the trap: fillna(0) on a gauge invents idle minutes that never happened
print('fillna(0) mean:', round(reg['cpu_pct'].fillna(0).mean(), 2))
print('skipna   mean:', round(reg['cpu_pct'].mean(), 2))
missing scrapes: 3
fillna(0) mean: 29.85
skipna   mean: 38.8

Three missing scrapes out of thirteen, and filling them with zero pulled the average CPU from 38.8 down to 29.85, a swing of nearly nine points invented from nothing. Zero is a real, believable CPU reading, which is what makes it so dangerous as a fill value, the corrupted number looks perfectly plausible on a dashboard. For a gauge, prefer skipping nulls in the aggregate, or interpolating over short gaps while leaving long outages as nulls a model can learn to ignore. Deciding what a gap means, replay it or exclude it, is the same discipline that keeps tracing and observability honest in language model systems, where a dropped span is missing evidence, never a zero.

Project status and a telemetry to dataset checklist

Where the project stands now: last part gave you a reproducible notebook, this part gave that notebook a real dataset to hold. You can take a raw export, parse its timestamps, pivot it tidy, resample it onto a regular grid, difference the counters, and account for every missing scrape honestly. That is a cleaned, well understood dataset, the raw material this whole series models. Run the short checklist below against your own export before you trust a single number off it.

Telemetry to dataset checklist

  1. Timestamp is a datetime64, not an object.
  2. One row per timestamp after pivoting.
  3. Resampled onto a regular grid.
  4. Every metric labelled gauge or counter, and every counter differenced into a rate.
  5. Reindexed to count missing scrapes rather than let them hide.
  6. No gauge gaps filled with zero.
  7. Interval and entity chosen deliberately, not inherited from the export default.
War story: Early on I built a requests per node feature straight from an http_requests_total counter and a quick rule flagged every node as anomalous at month end and none at the start. I spent an afternoon hunting a load pattern that did not exist. The counter had climbed past 4.1 billion by day 30 and sat near zero on day 1, so my feature was just a clock wearing a metric name, and the rule had learned that later equals worse. Differencing it into a per second rate erased the phantom overnight, and I have labelled every metric gauge or counter before it becomes a column ever since.

Export one metric as a dataset this week

If you do one thing after this, export a month of one gauge, CPU percent from one cluster, at a 5 minute interval, and load it with parse_dates so the timestamp is a real datetime. Then reindex it onto the full grid and count the missing scrapes, because that single number tells you how healthy your collection actually is. Label the metric a gauge, resist filling its gaps with zero, and you have the clean, honest dataset the rest of this series builds on. Next part gets data into Python properly, from SQL, APIs and monitoring endpoints, so the export you just did by hand becomes a repeatable pull, bring this dataset with you.

Infra to Data Science Series · Part 7 of 26
« Previous: Part 6  |  Guide  |  Next: Part 8 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading