Time-Series Analysis
Binning, aggregation, make-series, and visualization patterns for time-based data.
Most observability queries are time-series queries — counting events per interval, tracking latency over time, or comparing periods. This guide covers the core patterns and where Berserk differs from Microsoft KQL.
Binning with bin() and bin_auto()
The simplest time-series query groups events into fixed-width time buckets:
logs
| summarize count() by bin(timestamp, 5m)This produces one row per 5-minute bucket. The bucket timestamp is the start of each interval, rounded down.
Automatic bin sizing with bin_auto()
When you don't know the right interval — or want the UI to pick one that
matches the visible time range — use bin_auto():
logs
| summarize count() by bin_auto(timestamp)bin_auto() reads the query's time range (from the time picker or CLI --since/--until)
and selects a human-friendly interval that produces 750-1500 data points. The
algorithm picks from a fixed set of intervals:
| Time Range | Bin Size |
|---|---|
| 1 second | 1ms |
| 1 minute | 100ms |
| 1 hour | 10s |
| 1 day | 1m |
| 7 days | 10m |
| 30 days | 30m |
| 1 year | 6h |
This is deterministic — the same time range always produces the same bin size.
Differs from Microsoft KQL
Microsoft's bin_auto uses an opaque server-side algorithm controlled by
set query_bin_auto_size. In Berserk, bin_auto selects from 21 fixed
human-friendly intervals targeting 750-1500 bins. The result is more
predictable: you always know what bin size you'll get for a given time range.
Setting a minimum bin size
If your data is sparse and you don't want bins smaller than a threshold, pass a second argument:
logs
| summarize count() by bin_auto(timestamp, 1h)The final bin size is max(min_span, computed_span). If the time range is
7 days (computed: 10m), the override forces 1h bins. If the time range is
1 year (computed: 6h), 6h wins because it's already larger.
Differs from Microsoft KQL
In Microsoft KQL, the minimum bin size is set via set query_bin_auto_size = 1h
as a query option. Berserk uses a second positional argument instead:
bin_auto(timestamp, 1h).
Aggregating time series with summarize
The summarize operator groups rows and applies aggregate functions. Combined
with bin() or bin_auto(), it produces time series:
logs
| summarize
total = count(),
errors = countif(severity_number >= 17),
p99_latency = percentile(duration_ms, 99)
by bin(timestamp, 5m), serviceThis produces one row per (5-minute bucket, service) pair with three metrics.
Choosing between bin() and bin_auto()
Use bin() when:
- You need a specific, fixed interval (e.g., "hourly report")
- The query runs in automation or alerting where consistency matters
- You're comparing two time windows and need matching bucket sizes
Use bin_auto() when:
- The query backs a chart in the UI
- Users control the time range with the time picker
- You want zoom-friendly granularity (wider range = larger bins)
Building time series with make-series
summarize ... by bin() produces sparse output — if no events fall in a bucket,
that bucket is missing. make-series fills gaps and returns arrays:
logs
| make-series
request_count = count()
default = 0
on timestamp
from datetime(2024-01-01) to datetime(2024-01-02) step 1h
by serviceThis always produces exactly 24 entries per service, even if some hours had zero events. The result columns are:
timestamp— array of bucket start timesrequest_count— array of counts (with0for empty buckets)service— the group key
When to use make-series vs summarize
Use make-series when:
- You need gap-filling (zero-fill, forward-fill)
- Downstream functions operate on arrays (
series_fir,series_decompose_anomalies) - You're building data for
render timechart
Use summarize when:
- You want tabular output
- Gaps should be omitted, not filled
- You're aggregating without time (e.g.,
summarize count() by service)
Metrics: rate(), deriv(), and counter_rate()
Berserk adds three aggregate functions for working with OpenTelemetry metrics. These are not available in Microsoft KQL.
rate() — counter rate with reset detection
Computes per-second rate of change, automatically handling counter resets (where the value drops back to zero):
metrics
| summarize rate(value, timestamp) by bin(timestamp, 1m), serviceCounters only go up (requests served, bytes sent). When a process restarts,
the counter resets to zero. rate() detects this and doesn't produce a
negative spike.
deriv() — gauge derivative
Computes rate of change for gauge metrics (values that go up and down, like memory usage or temperature):
metrics
| summarize deriv(value, timestamp) by bin(timestamp, 1m), hostUnlike rate(), this preserves negative changes — if memory drops, you see a
negative derivative.
counter_rate() — OpenTelemetry cumulative counters
Designed for OTEL cumulative counters that include a start_time field.
Uses start_time for reset detection instead of inferring resets from value
drops:
metrics
| make-series r = counter_rate(value, start_time)
on timestamp
from ago(1h) to now() step 1m
by serviceChanging the rate duration
By default, rates are per second. Pass a third argument to change the unit:
metrics
| summarize rate(value, timestamp, 1m) by bin(timestamp, 5m), serviceThis produces per-minute rates instead of per-second.
Series analysis functions
After make-series produces arrays, series functions process them:
Smoothing and filtering
logs
| make-series requests = count() default = 0 on timestamp step 5m
| extend smoothed = series_fir(requests, repeat(1, 5), true, true)series_fir applies a finite impulse response filter. The example uses a
5-point moving average (repeat(1, 5) with normalization).
Anomaly detection
logs
| make-series requests = count() default = 0 on timestamp step 5m by service
| extend (anomalies, score, baseline) = series_decompose_anomalies(requests)Decomposes the series into trend + seasonal + residual components and flags
anomalous points. The anomalies array contains -1 (low), 0 (normal), or
1 (high) for each point.
Trend fitting
metrics
| make-series cpu = avg(value) default = 0 on timestamp step 1h by host
| extend (rsquare, slope, variance, rvariance, intercept, fitted) = series_fit_line(cpu)
| where slope > 0.5Fits a linear regression to each series. Filter on slope to find hosts
with increasing CPU usage.
Visualization
render timechart
Appending | render timechart tells the UI to display results as a line chart:
logs
| summarize count() by bin_auto(timestamp), service
| render timechartThe render operator is a hint — it doesn't change the data, only how the
UI displays it. Properties control appearance:
| render timechart with (title="Request Rate", ytitle="req/s", legend=hidden)Other chart types
render barchart— categorical comparison (e.g., errors by service)render areachart— stacked area (e.g., traffic by region)render anomalychart— time series with anomaly bands
All chart types work with the same summarize ... by bin() pattern.