diff --git a/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs b/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs index d4878b17f4..988687e28a 100644 --- a/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs +++ b/Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs @@ -242,6 +242,170 @@ QUALIFY NOT (delta_cntr_value = 0 AND COALESCE(LAG(delta_cntr_value) OVER /* The pre-window row filter is unchanged from Lite. */ Assert.Contains("counter_name = 'Batch Requests/sec'", TimescaleSupport.CreatePerfmonBaselineSql, StringComparison.Ordinal); Assert.Contains("delta_cntr_value >= 0", TimescaleSupport.CreatePerfmonBaselineSql, StringComparison.Ordinal); + + /* #3527: v is the PER-SECOND rate. The perfmon_baseline supply materializes only + (collection_time, delta_cntr_value) — no stored interval, and a continuous aggregate cannot + grow a column without forfeiting the history the 4-day raw tier can't refill — so the divisor + is derived from LAG(collection_time) over the collapsed series (the WaitMsPerSec idiom), + computed in the SAME windowed CTE (window-before-filter holds for it too), with the + interval-less first row filtered alongside the restart exclusion. The DOUBLE PRECISION cast + is the io-arm rule: STDDEV_SAMP over numeric can overflow System.Decimal. */ + var intervalAt = sql.IndexOf("extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec", StringComparison.Ordinal); + Assert.True(intervalAt >= 0 && intervalAt < fromCteAt, "interval_sec must be derived inside the windowed CTE"); + Assert.Contains("delta_cntr_value::DOUBLE PRECISION / interval_sec AS v", sql, StringComparison.Ordinal); + var intervalFilterAt = sql.IndexOf("interval_sec > 0", StringComparison.Ordinal); + Assert.True(intervalFilterAt > fromCteAt, "the interval filter must sit OUTSIDE the windowed CTE, with the exclusion"); + } + + /// + /// #3527: the batch-request WINDOW statistic must be requests/sec — the per-interval + /// delta_cntr_value divided by the row's measured sample_interval_seconds — or the + /// BatchRequestFloor/Fallback thresholds (defined in requests/sec) admit 60-300x-inflated + /// deltas and the comparison against the per-second baseline is cross-unit. Interval <= 0 + /// rows carry NO knowable delta and must be filtered, never read as a rate of 0 or as the + /// raw delta. + /// + [Fact] + public void BatchRequestWindow_DividesByMeasuredInterval_AndSkipsUnknowableRows() + { + var sql = PgAnomalyDetector.BatchRequestWindowSql; + + Assert.Contains("AVG(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0))", sql, StringComparison.Ordinal); + Assert.Contains("MAX(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0))", sql, StringComparison.Ordinal); + Assert.Contains("sample_interval_seconds > 0", sql, StringComparison.Ordinal); + + /* A raw AVG/MAX of the delta is exactly the #3527 defect — pin its absence. */ + Assert.DoesNotContain("AVG(delta_cntr_value)", sql, StringComparison.Ordinal); + Assert.DoesNotContain("MAX(delta_cntr_value)", sql, StringComparison.Ordinal); + } + + /// + /// #3527 proven live, both halves in one place: the BatchRequests BASELINE arm derives its + /// per-second unit from LAG(collection_time) over the perfmon_baseline supply, and the DETECTOR's + /// window read divides by the stored measured interval — so the two sides meet in the same + /// requests/sec unit and the absolute bars judge honest rates. + /// + /// History (one Monday-10:00 bucket, 12 collections at 300s spacing, delta 30000 each — + /// 100 req/sec): c1 has no prior (interval NULL → dropped), c6 is a restart zero (prior 30000 > + /// 1000 → excluded), c7 is a genuine idle zero (prior 0 → kept at 0/sec). 10 samples, mean + /// (9x100 + 0)/10 = 90 — in requests/sec, where the raw-delta unit would read 27000. + /// + /// Window (the following Monday): three rows at stored interval 60, delta 600000 — 10000 + /// req/sec — plus one interval-0 row with a wild delta that must be SKIPPED, not rated. The one + /// planted history day leaves the bucket untrustworthy (Full tier needs 3 distinct days), so the + /// detector fires on the absolute BatchRequestFallback bar (5000 req/sec): peak 10000 clears it + /// honestly. Pre-#3527 the raw deltas cleared every bar by orders of magnitude regardless of + /// workload; post-fix the emitted Value, peak/avg metadata, and baseline_mean are all per-second. + /// + [Fact] + public async Task EndToEnd_BatchRequestArm_PerSecondBaselineAndWindow_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live batch-request test."); + + var ct = TestContext.Current.CancellationToken; + const int batchServerId = TestServerId + 2; // own id — this test cleans its own rows + const string batchServerName = "batch-per-second-e2e"; + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + await using (var cleanup = new NpgsqlCommand( + $"DELETE FROM perfmon_stats WHERE server_id = {batchServerId}; " + + $"DELETE FROM wait_stats WHERE server_id = {batchServerId};", connection)) + { + await cleanup.ExecuteNonQueryAsync(ct); + } + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + var bodySucceeded = false; + try + { + var day = DateTime.UtcNow.Date.AddDays(-8); + while (day.DayOfWeek != DayOfWeek.Monday) day = day.AddDays(-1); + var historyStart = DateTime.SpecifyKind(day.AddHours(10), DateTimeKind.Unspecified); + + for (var i = 0; i < 12; i++) + { + var delta = (i == 5 || i == 6) ? 0L : 30000L; + await InsertAsync(connection, + "INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + (long)(200 + i), historyStart.AddMinutes(5 * i), batchServerId, batchServerName, + "SQLServer:SQL Statistics", "Batch Requests/sec", "", delta * 2, delta, 300); + } + + /* The baseline supply must exist (see the wait test's note) — plain fallback views. */ + await TimescaleSupport.EnsureBaselineFallbackViewsAsync(connection, null, ct); + + var provider = new PgBaselineProvider(postgres); + var analysisTime = historyStart.AddDays(7); + + var baseline = await provider.GetBaselineAsync(batchServerId, MetricNames.BatchRequests, analysisTime); + Assert.Equal(10L, baseline.SampleCount); + Assert.Equal(90.0, baseline.Mean, 0.001); + Assert.Equal(BaselineTier.Full, baseline.Tier); + Assert.Equal(10, baseline.HourOfDay); + Assert.Equal((int)DayOfWeek.Monday, baseline.DayOfWeek); + + /* Canary for the HasBaselineData gate — OUTSIDE the analysis window so the wait + detector's own window read stays empty and it emits nothing. */ + await InsertAsync(connection, + "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type, delta_waiting_tasks, delta_wait_time_ms) VALUES ($1, $2, $3, $4, $5, $6, $7)", + 300L, historyStart, batchServerId, batchServerName, TestWaitType, 1L, 100L); + + /* The anomalous current window: 10000 req/sec (delta 600000 over a measured 60s), + plus one interval-0 row whose wild delta must never be rated. */ + for (var i = 0; i < 3; i++) + { + await InsertAsync(connection, + "INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + (long)(400 + i), analysisTime.AddMinutes(5 * (i + 1)), batchServerId, batchServerName, + "SQLServer:SQL Statistics", "Batch Requests/sec", "", 1200000L, 600000L, 60); + } + await InsertAsync(connection, + "INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + 403L, analysisTime.AddMinutes(20), batchServerId, batchServerName, + "SQLServer:SQL Statistics", "Batch Requests/sec", "", 0L, 999999999L, 0); + + var detector = new PgAnomalyDetector(postgres, provider); + var context = new AnalysisContext + { + ServerId = batchServerId, + ServerName = batchServerName, + TimeRangeStart = analysisTime, + TimeRangeEnd = analysisTime.AddMinutes(30), + ServerUtcOffset = TimeSpan.Zero + }; + + var anomalies = await detector.DetectAnomaliesAsync(context); + + var fact = Assert.Single(anomalies); + Assert.Equal("ANOMALY_BATCH_REQUESTS", fact.Key); + Assert.Equal(10000.0, fact.Value, 0.001); // per-second, not 600000 + Assert.Equal(10000.0, fact.Metadata["peak_batch_requests"], 0.001); + Assert.Equal(10000.0, fact.Metadata["avg_batch_requests"], 0.001); + Assert.Equal(3.0, fact.Metadata["window_samples"]); // the interval-0 row is NOT a sample + Assert.Equal(90.0, fact.Metadata["baseline_mean"], 0.001); // same unit as the window + Assert.Equal(1.0, fact.Metadata["baseline_low_quality"]); // one distinct day → absolute bar + Assert.Equal(2.0, fact.Metadata["fallback_exceedance"], 0.001); // 10000 / the 5000 req/sec bar + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) => + { + using (var command = new NpgsqlCommand( + $"DELETE FROM perfmon_stats WHERE server_id = {batchServerId}; " + + $"DELETE FROM wait_stats WHERE server_id = {batchServerId};", cleanup)) + { + await command.ExecuteNonQueryAsync(cleanupCt); + } + await DropBaselineFallbackViewsAsync(cleanup, cleanupCt); + }); + } } [Fact] diff --git a/Darling/Darling.Tests/PgFactCollectorTests.cs b/Darling/Darling.Tests/PgFactCollectorTests.cs index 5b5b60c096..f363944483 100644 --- a/Darling/Darling.Tests/PgFactCollectorTests.cs +++ b/Darling/Darling.Tests/PgFactCollectorTests.cs @@ -341,6 +341,126 @@ await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup } } + /* ---------------- #3527: perfmon facts are per-second rates ---------------- */ + + /// + /// #3527: delta_cntr_value spans one COLLECTION INTERVAL, not one second — read raw, the + /// PERFMON_*_SEC facts overstate by the cadence (60x at 60s, 300x at 5min). The query must + /// select the row's measured sample_interval_seconds (#2234) for the division and filter + /// interval <= 0 rows (no delta was knowable: first sighting, reset, gap) so rn = 1 lands on + /// the newest row a rate can honestly be derived from. + /// + [Fact] + public void PerfmonSql_SelectsTheMeasuredInterval_AndFiltersUnknowableRows() + { + var sql = PgFactCollector.PerfmonSql; + + Assert.Contains("delta_cntr_value, sample_interval_seconds", sql, StringComparison.Ordinal); + Assert.Contains("sample_interval_seconds > 0", sql, StringComparison.Ordinal); + } + + /// + /// #3527 live fixture: a 'Batch Requests/sec' row with delta 6000 over a measured 60s interval + /// must emit PERFMON_BATCH_REQ_SEC = 100 (not 6000), with the raw delta and the divisor in the + /// metadata. A NEWER interval-0 row (unknowable delta) must be skipped — the fact still comes + /// from the older usable row — and a counter with ONLY interval-0 rows emits no fact at all, + /// never a fact of 0. + /// + [Fact] + public async Task EndToEnd_PerfmonFacts_DivideDeltaByMeasuredInterval_AgainstDevPostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the live perfmon fact test."); + + var ct = TestContext.Current.CancellationToken; + const int perfmonServerId = TestServerId - 2; // own id — this test cleans its own rows + + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + await using (var cleanup = new NpgsqlCommand( + $"DELETE FROM perfmon_stats WHERE server_id = {perfmonServerId};", connection)) + { + await cleanup.ExecuteNonQueryAsync(ct); + } + + await using var postgres = NpgsqlDataSource.Create(connectionString!); + var collector = new PgFactCollector(postgres); + + var bodySucceeded = false; + try + { + var windowEnd = TruncateToSeconds(DateTime.UtcNow); + var windowStart = windowEnd.AddHours(-1); + + async Task PlantAsync(long id, DateTime time, string counter, long delta, int intervalSeconds) + { + using var plant = new NpgsqlCommand(@" +INSERT INTO perfmon_stats + (collection_id, collection_time, server_id, server_name, + object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", connection); + plant.Parameters.AddWithValue(id); + plant.Parameters.AddWithValue(time); + plant.Parameters.AddWithValue(perfmonServerId); + plant.Parameters.AddWithValue("perfmon-per-second-e2e"); + plant.Parameters.AddWithValue("SQLServer:SQL Statistics"); + plant.Parameters.AddWithValue(counter); + plant.Parameters.AddWithValue(""); + plant.Parameters.AddWithValue(delta * 2); + plant.Parameters.AddWithValue(delta); + plant.Parameters.AddWithValue(intervalSeconds); + await plant.ExecuteNonQueryAsync(ct); + } + + /* Batch requests: an older USABLE row (delta 6000 / 60s = 100/sec), then a NEWER + interval-0 row that must not become the fact. */ + await PlantAsync(1, windowStart.AddMinutes(20), "Batch Requests/sec", 6000, 60); + await PlantAsync(2, windowStart.AddMinutes(25), "Batch Requests/sec", 0, 0); + + /* Compilations: one usable row, 300 / 60s = 5/sec. */ + await PlantAsync(3, windowStart.AddMinutes(20), "SQL Compilations/sec", 300, 60); + + /* Re-compilations: ONLY an interval-0 row — no rate is knowable, so no fact. */ + await PlantAsync(4, windowStart.AddMinutes(20), "SQL Re-Compilations/sec", 0, 0); + + var context = new AnalysisContext + { + ServerId = perfmonServerId, + ServerName = "perfmon-per-second-e2e", + TimeRangeStart = windowStart, + TimeRangeEnd = windowEnd, + ServerUtcOffset = TimeSpan.Zero + }; + + var facts = await collector.CollectFactsAsync(context); + + var batch = Assert.Single(facts, f => f.Key == "PERFMON_BATCH_REQ_SEC"); + Assert.Equal(100.0, batch.Value, precision: 10); + Assert.Equal(6000, batch.Metadata["delta_cntr_value"]); + Assert.Equal(60, batch.Metadata["sample_interval_seconds"]); + + var compilations = Assert.Single(facts, f => f.Key == "PERFMON_COMPILATIONS_SEC"); + Assert.Equal(5.0, compilations.Value, precision: 10); + + Assert.DoesNotContain(facts, f => f.Key == "PERFMON_RECOMPILATIONS_SEC"); + Assert.Equal(2, facts.Count); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(connectionString!, bodySucceeded, async (cleanup, cleanupCt) => + { + using var command = new NpgsqlCommand( + $"DELETE FROM perfmon_stats WHERE server_id = {perfmonServerId};", cleanup); + await command.ExecuteNonQueryAsync(cleanupCt); + }); + } + } + private static DateTime TruncateToSeconds(DateTime value) => DateTime.SpecifyKind(new DateTime(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified); diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs index 776d66e4c4..4a4c0d85cf 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs @@ -207,14 +207,20 @@ FROM v_file_io_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 AND (delta_reads > 0 OR delta_writes > 0)"; + /* Batch-request window: per-second rate per sample (#3527) — delta_cntr_value spans one collection + interval, so divide by the row's MEASURED sample_interval_seconds (#2234). Interval <= 0 marks an + unknowable delta (first sighting/reset/gap) and the row is skipped, never read as 0. Keeps the + window statistic in the same requests/sec unit as the baseline and the BatchRequestFloor/Fallback + thresholds. */ public const string BatchRequestWindowSql = @" -SELECT AVG(delta_cntr_value) AS avg_batch, - MAX(delta_cntr_value) AS peak_batch, +SELECT AVG(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0)) AS avg_batch, + MAX(delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0)) AS peak_batch, COUNT(*) AS sample_count FROM v_perfmon_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 AND counter_name = 'Batch Requests/sec' -AND delta_cntr_value >= 0"; +AND delta_cntr_value >= 0 +AND sample_interval_seconds > 0"; public const string SessionWindowSql = @" WITH per_collection AS ( diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs index cc8e10ab5a..e987c868b3 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs @@ -366,23 +366,19 @@ FROM cpu_utilization_stats /* QUALIFY rewrite 1 of 4 — cumulative counter, restart exclusion. Excludes samples where the delta drops to 0 when the prior sample was > 1000 - (restart signature for cumulative counters). Lite's DuckDB original: - - SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, - EXTRACT(DOW FROM collection_time)::INT AS day_of_week, - AVG(delta_cntr_value) AS mean_val, - STDDEV_SAMP(delta_cntr_value) AS stddev_val, - COUNT(*) AS sample_count - FROM ( - SELECT collection_time, delta_cntr_value + (restart signature for cumulative counters). Lite's DuckDB original (#3527 unit: + v is delta / the row's measured sample_interval_seconds — a per-second rate): + + WITH clean AS ( + SELECT collection_time, delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0) AS v FROM v_perfmon_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND counter_name = 'Batch Requests/sec' AND delta_cntr_value >= 0 + AND sample_interval_seconds > 0 QUALIFY NOT (delta_cntr_value = 0 AND COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) > 1000) ) - GROUP BY hour_of_day, day_of_week QUALIFY evaluates AFTER window computation: LAG runs over every WHERE-surviving row (including rows QUALIFY itself is about to drop), THEN the predicate prunes. @@ -390,18 +386,32 @@ The rewrite computes the SAME LAG over the SAME WHERE-filtered rowset inside a CTE and applies the IDENTICAL predicate in the outer WHERE — window-before-filter is preserved, so only the FIRST zero after a >1000 sample is dropped, and a zero following another zero keeps LAG = 0 and SURVIVES (genuine idle, not a restart). - Row selection is exactly the original's. */ + Row selection is exactly the original's. + + #3527 divisor: this arm reads the perfmon_baseline supply (the #1757 CAGG / fallback + view), which materializes only (collection_time, delta_cntr_value) — it does NOT carry + sample_interval_seconds, and a continuous aggregate cannot grow a column without a + drop-and-rebuild that would forfeit the 31 days of baseline history the 4-day raw tier + can no longer refill. So the interval is DERIVED from the gap between consecutive + collections — LAG(collection_time) over the collapsed series, byte-for-byte the + WaitMsPerSec arm's idiom (see CreateWaitStatsBaselineSql's note: the provider computes + interval_sec, nothing extra is stored). Same requests/sec unit as Lite and as the + detector's window read; the first row of the window has no prior (interval NULL) and is + skipped, exactly like WaitMsPerSec. The ::DOUBLE PRECISION cast is the io-arm rule: + STDDEV_SAMP over numeric can overflow System.Decimal at materialization. */ MetricNames.BatchRequests => @" WITH windowed AS ( SELECT collection_time, delta_cntr_value, - COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) AS prior_delta + COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) AS prior_delta, + extract(epoch FROM (date_trunc('second', collection_time) - date_trunc('second', LAG(collection_time) OVER (ORDER BY collection_time)))) AS interval_sec FROM perfmon_baseline WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 ), clean AS ( - SELECT collection_time, delta_cntr_value AS v + SELECT collection_time, delta_cntr_value::DOUBLE PRECISION / interval_sec AS v FROM windowed WHERE NOT (delta_cntr_value = 0 AND prior_delta > 1000) + AND interval_sec > 0 )," + RobustTierScaffold, /* QUALIFY rewrite 2 of 4 — cumulative counter, multiple rows per collection (per diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs index 8b60fdd25e..8c126431b6 100644 --- a/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs +++ b/Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs @@ -331,22 +331,30 @@ private async Task CollectCpuUtilizationFactsAsync(AnalysisContext context, List } } + /* #3527: delta_cntr_value spans one COLLECTION INTERVAL, not one second — at a 60s cadence the raw + delta is 60x the true rate. The honest rate divides by the row's MEASURED sample_interval_seconds + (#2234); interval <= 0 marks an unknowable delta (first sighting, counter reset, gap past the + policy), so those rows are filtered rather than emitted as 0 — rn = 1 lands on the newest row a + rate can honestly be derived from. */ public const string PerfmonSql = @" WITH latest AS ( - SELECT counter_name, cntr_value, delta_cntr_value, + SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds, ROW_NUMBER() OVER (PARTITION BY counter_name ORDER BY collection_time DESC) AS rn FROM perfmon_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Compilations/sec') + AND sample_interval_seconds > 0 ) -SELECT counter_name, cntr_value, delta_cntr_value +SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds FROM latest WHERE rn = 1"; /// /// Collects key perfmon throughput counters: Batch Requests/sec, compilations, recompilations. /// Unscored context that distinguishes a busy server from a sick one (used by the AI surfaces). + /// Fact values are per-second rates: the per-interval delta divided by the row's measured + /// sample_interval_seconds (#3527); the raw delta and the divisor ride the metadata. /// private async Task CollectPerfmonFactsAsync(AnalysisContext context, List facts) { @@ -365,6 +373,7 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List var counterName = reader.GetString(0); var cntrValue = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); var deltaValue = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var intervalSeconds = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); var (factKey, source) = counterName switch { @@ -376,8 +385,11 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List if (factKey == null) continue; - // All remaining counters are per-second rates — use the delta. - var value = (double)deltaValue; + /* The delta spans one collection interval — divide by the measured interval for the + per-second rate (#3527). The SQL already filters interval <= 0 (unknowable delta); + this guard keeps a raw or zero value from ever escaping if that filter regresses. */ + if (intervalSeconds <= 0) continue; + var value = deltaValue / (double)intervalSeconds; facts.Add(new Fact { @@ -388,7 +400,8 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List Metadata = new Dictionary { ["cntr_value"] = cntrValue, - ["delta_cntr_value"] = deltaValue + ["delta_cntr_value"] = deltaValue, + ["sample_interval_seconds"] = intervalSeconds } }); } diff --git a/Lite.Tests/AnomalyDetectorTests.cs b/Lite.Tests/AnomalyDetectorTests.cs index 073cd6f808..198b5d14b2 100644 --- a/Lite.Tests/AnomalyDetectorTests.cs +++ b/Lite.Tests/AnomalyDetectorTests.cs @@ -100,10 +100,10 @@ private async Task ExecuteSeedAsync(string sql) [Fact] public async Task DetectBatchRequestAnomalies_Spike_DetectsAnomaly() { - // Baseline: normal batch requests (~5000) + // Baseline: normal batch requests (delta ~5000 per 10s interval = ~500/sec) await SeedBaselinePerfmon("Batch Requests/sec", 5000, variance: 200); - // Analysis window: spike to 15000 + // Analysis window: spike to delta 15000 per 10s interval = 1500/sec for (int i = 0; i < 16; i++) await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 15000); @@ -139,11 +139,51 @@ public async Task DetectBatchRequestAnomalies_Normal_NoAnomaly() [Fact] public async Task DetectBatchRequestAnomalies_LowVolumeSpike_NoAnomaly() { - // Low-throughput server: a 6x relative spike but the peak stays at 300/sec - // — below the 500/sec BatchRequestFloor — must NOT be flagged. - await SeedBaselinePerfmon("Batch Requests/sec", 50, variance: 5); + // Low-throughput server: a 6x relative spike but the peak stays at 300/sec (delta 3000 over + // a 10s interval) — below the 500/sec BatchRequestFloor — must NOT be flagged. The RAW + // per-interval delta (3000) is past the floor: only the per-second division (#3527) keeps + // this quiet, so this test also pins that the floor compares requests/sec, not the delta. + await SeedBaselinePerfmon("Batch Requests/sec", 500, variance: 50); for (int i = 0; i < 16; i++) - await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 300); + await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 3000); + + await SeedBaselineCpu(10, variance: 2); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + Assert.DoesNotContain(anomalies, f => f.Key == "ANOMALY_BATCH_REQUESTS"); + } + + [Fact] + public async Task DetectBatchRequestAnomalies_WindowValuesArePerSecond() + { + // #3527 fixture through the detector: window delta 15000 over a 10s interval = 1500/sec. + // The emitted Value and peak/avg metadata must be the per-second numbers, in the same unit + // as the baseline (delta ~5000 / 10s = ~500/sec) — not the raw per-interval deltas. + await SeedBaselinePerfmon("Batch Requests/sec", 5000, variance: 200); + for (int i = 0; i < 16; i++) + await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 15000); + + await SeedBaselineCpu(10, variance: 2); + + var anomalies = await _detector.DetectAnomaliesAsync(CreateContext()); + + var fact = anomalies.First(f => f.Key == "ANOMALY_BATCH_REQUESTS"); + Assert.Equal(1500.0, fact.Value); + Assert.Equal(1500.0, fact.Metadata["peak_batch_requests"]); + Assert.Equal(1500.0, fact.Metadata["avg_batch_requests"]); + Assert.InRange(fact.Metadata["baseline_mean"], 450, 550); + } + + [Fact] + public async Task DetectBatchRequestAnomalies_IntervalZeroRowsAreSkipped_NotReadAsRates() + { + // #3527: interval-0 rows carry NO knowable delta. A window holding only interval-0 rows — + // however wild their deltas — has zero usable samples, so the detector stays silent instead + // of reading the raw deltas as a spike (or the rows as rate 0). + await SeedBaselinePerfmon("Batch Requests/sec", 5000, variance: 200); + for (int i = 0; i < 16; i++) + await SeedPerfmonAsync(_analysisStart.AddMinutes(i * 15), "Batch Requests/sec", 999_999, intervalSeconds: 0); await SeedBaselineCpu(10, variance: 2); @@ -600,7 +640,10 @@ private async Task SeedCpuAsync(DateTime time, int cpuValue) await cmd.ExecuteNonQueryAsync(); } - private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue) + /// Seeds one perfmon row. deltaValue is the PER-INTERVAL delta; the detector divides it by + /// intervalSeconds (#3527), so at the default 10s interval the per-second rate is deltaValue / 10. + /// intervalSeconds = 0 plants the unknowable-delta marker the reads must skip. + private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue, int intervalSeconds = 10) { using var readLock = _duckDb.AcquireReadLock(); var conn = await SeedConnectionAsync(); @@ -608,12 +651,13 @@ private async Task SeedPerfmonAsync(DateTime time, string counterName, long delt cmd.CommandText = @"INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) - VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, 10)"; + VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, $6)"; cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = time }); cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); cmd.Parameters.Add(new DuckDBParameter { Value = counterName }); cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue }); + cmd.Parameters.Add(new DuckDBParameter { Value = intervalSeconds }); await cmd.ExecuteNonQueryAsync(); } diff --git a/Lite.Tests/BaselineProviderTests.cs b/Lite.Tests/BaselineProviderTests.cs index 1ce9f111ea..ad04f8bd9b 100644 --- a/Lite.Tests/BaselineProviderTests.cs +++ b/Lite.Tests/BaselineProviderTests.cs @@ -286,8 +286,34 @@ public async Task GetBaseline_BatchRequests_ExcludesRestartDrop() _provider.ClearCache(); var baseline = await _provider.GetBaselineAsync(ServerId, MetricNames.BatchRequests, AnalysisTime); - // The restart drop (0) should be excluded, so mean should be near 5000, not pulled toward 0 - Assert.True(baseline.Mean > 4000, $"Mean {baseline.Mean} should not be poisoned by restart drop"); + // #3527: the baseline is per-second now — deltas of ~5000 over 10s intervals are ~500/sec. + // The restart drop (0) should be excluded, so the mean should sit near 500, not pulled toward 0. + Assert.True(baseline.Mean > 400, $"Mean {baseline.Mean} should not be poisoned by restart drop"); + Assert.InRange(baseline.Mean, 400, 600); + } + + [Fact] + public async Task GetBaseline_BatchRequests_IsPerSecond_AndSkipsIntervalZeroRows() + { + // #3527 fixture: deltas of 6000 over measured 60s intervals are 100 requests/sec — the + // baseline population must be that division, in the same unit as the detector's window read. + // Interspersed interval-0 rows (unknowable delta) must not enter the population at all: + // read as raw deltas they would inflate the mean; read as 0 they would drag it down. + for (int d = 1; d <= 4; d++) + { + var day = AnalysisTime.AddDays(-7 * d); + for (int i = 0; i < 5; i++) + await SeedPerfmonAsync(day.AddMinutes(i * 10), "Batch Requests/sec", 6000, intervalSeconds: 60); + + // One unknowable-delta row per day, wedged between the usable samples. + await SeedPerfmonAsync(day.AddMinutes(55), "Batch Requests/sec", 0, intervalSeconds: 0); + } + + _provider.ClearCache(); + var baseline = await _provider.GetBaselineAsync(ServerId, MetricNames.BatchRequests, AnalysisTime); + + Assert.Equal(20, baseline.SampleCount); // 5 usable rows x 4 days; interval-0 rows contribute nothing + Assert.Equal(100.0, baseline.Mean, 3); } // ── Wait stats: per-collection aggregation ── @@ -442,7 +468,10 @@ private async Task SeedCpuAsync(DateTime time, int cpuValue, int serverId = Serv await cmd.ExecuteNonQueryAsync(); } - private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue) + /// Seeds one perfmon row. deltaValue is the PER-INTERVAL delta; the baseline divides it by + /// intervalSeconds (#3527), so at the default 10s interval the per-second value is deltaValue / 10. + /// intervalSeconds = 0 plants the unknowable-delta marker the baseline must skip. + private async Task SeedPerfmonAsync(DateTime time, string counterName, long deltaValue, int intervalSeconds = 10) { using var readLock = _duckDb.AcquireReadLock(); var conn = await SeedConnectionAsync(); @@ -450,12 +479,13 @@ private async Task SeedPerfmonAsync(DateTime time, string counterName, long delt cmd.CommandText = @"INSERT INTO perfmon_stats (collection_id, collection_time, server_id, server_name, object_name, counter_name, instance_name, cntr_value, delta_cntr_value, sample_interval_seconds) - VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, 10)"; + VALUES ($1, $2, $3, 'TestServer', 'SQLServer:SQL Statistics', $4, '', $5, $5, $6)"; cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); cmd.Parameters.Add(new DuckDBParameter { Value = time }); cmd.Parameters.Add(new DuckDBParameter { Value = ServerId }); cmd.Parameters.Add(new DuckDBParameter { Value = counterName }); cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue }); + cmd.Parameters.Add(new DuckDBParameter { Value = intervalSeconds }); await cmd.ExecuteNonQueryAsync(); } diff --git a/Lite.Tests/FactCollectorTests.cs b/Lite.Tests/FactCollectorTests.cs index b226f1f72b..915c635795 100644 --- a/Lite.Tests/FactCollectorTests.cs +++ b/Lite.Tests/FactCollectorTests.cs @@ -213,6 +213,64 @@ public async Task CollectFacts_Perfmon_ReturnsRateCounters() var facts = await SeedAndCollectAsync(s => s.SeedEverythingOnFireServerAsync()); Assert.True(facts.ContainsKey("PERFMON_BATCH_REQ_SEC"), "PERFMON_BATCH_REQ_SEC should be collected"); + + /* #3527: the seeder plants delta 30000 over a measured 60s interval — the fact must be the + per-second rate 500, not the raw per-interval delta. */ + var batch = facts["PERFMON_BATCH_REQ_SEC"]; + Assert.Equal(500.0, batch.Value); + Assert.Equal(30000, batch.Metadata["delta_cntr_value"]); + Assert.Equal(60, batch.Metadata["sample_interval_seconds"]); + } + + /// + /// #3527 fixture: delta 6000 over a measured 60s interval is 100 requests/sec — the fact value is + /// the division, with the raw delta and the divisor preserved in metadata. Before the fix the fact + /// carried the raw 6000 (60x truth at this cadence). + /// + [Fact] + public async Task CollectFacts_Perfmon_DividesDeltaByMeasuredInterval() + { + var facts = await SeedAndCollectAsync(async s => + { + await s.ClearTestDataAsync(); + await s.SeedTestServerAsync(); + await s.SeedPerfmonRawAsync("Batch Requests/sec", deltaValue: 6000, sampleIntervalSeconds: 60); + }); + + var batch = facts["PERFMON_BATCH_REQ_SEC"]; + Assert.Equal(100.0, batch.Value); + Assert.Equal(6000, batch.Metadata["delta_cntr_value"]); + Assert.Equal(60, batch.Metadata["sample_interval_seconds"]); + } + + /// + /// #3527: sample_interval_seconds = 0 means NO delta was knowable (first sighting, counter reset, + /// gap past the delta policy) — such a row must never become a fact of 0 or of the raw delta. The + /// newest usable row wins instead, and a counter with ONLY unusable rows emits no fact at all. + /// + [Fact] + public async Task CollectFacts_Perfmon_SkipsIntervalZeroRows() + { + var facts = await SeedAndCollectAsync(async s => + { + await s.ClearTestDataAsync(); + await s.SeedTestServerAsync(); + + /* Older usable row, then a NEWER interval-0 row: the fact must come from the usable row. */ + await s.SeedPerfmonRawAsync("Batch Requests/sec", deltaValue: 6000, sampleIntervalSeconds: 60, + collectionTime: TestDataSeeder.TestPeriodEnd.AddMinutes(-5)); + await s.SeedPerfmonRawAsync("Batch Requests/sec", deltaValue: 0, sampleIntervalSeconds: 0); + + /* A counter whose only row in the window is interval-0: no fact, not a fact of 0. */ + await s.SeedPerfmonRawAsync("SQL Re-Compilations/sec", deltaValue: 0, sampleIntervalSeconds: 0); + }); + + var batch = facts["PERFMON_BATCH_REQ_SEC"]; + Assert.Equal(100.0, batch.Value); + Assert.Equal(60, batch.Metadata["sample_interval_seconds"]); + + Assert.False(facts.ContainsKey("PERFMON_RECOMPILATIONS_SEC"), + "a counter with only interval-0 rows must emit no fact — 0 is not a knowable rate"); } [Fact] diff --git a/Lite.Tests/TestDataSeeder.cs b/Lite.Tests/TestDataSeeder.cs index cca1af50a7..00db2a4241 100644 --- a/Lite.Tests/TestDataSeeder.cs +++ b/Lite.Tests/TestDataSeeder.cs @@ -1518,7 +1518,9 @@ INSERT INTO query_stats /// /// Seeds perfmon_stats with the collected rate counters (batch requests, compilations, - /// recompilations); all use delta_cntr_value. + /// recompilations). Parameters are PER-SECOND rates; the rows carry the per-interval delta + /// (rate x the 60s interval) plus sample_interval_seconds = 60, so the fact collector's + /// delta / interval division (#3527) reproduces the parameter exactly. /// internal async Task SeedPerfmonAsync(long batchReqSec = 500, long compilationsSec = 50, long recompilationsSec = 5) @@ -1529,9 +1531,9 @@ internal async Task SeedPerfmonAsync(long batchReqSec = 500, var counters = new (string name, long cntrValue, long deltaValue)[] { - ("Batch Requests/sec", batchReqSec * 60, batchReqSec), // cntr = cumulative, delta = rate - ("SQL Compilations/sec", compilationsSec * 60, compilationsSec), - ("SQL Re-Compilations/sec", recompilationsSec * 60, recompilationsSec) + ("Batch Requests/sec", batchReqSec * 120, batchReqSec * 60), // cntr = cumulative, delta = rate x 60s interval + ("SQL Compilations/sec", compilationsSec * 120, compilationsSec * 60), + ("SQL Re-Compilations/sec", recompilationsSec * 120, recompilationsSec * 60) }; foreach (var (name, cntr, delta) in counters) @@ -1556,6 +1558,36 @@ INSERT INTO perfmon_stats } } + /// + /// Seeds one raw perfmon_stats row with explicit delta and interval — for pinning the #3527 + /// delta / sample_interval_seconds division and the interval-0 (unknowable delta) skip. + /// + internal async Task SeedPerfmonRawAsync(string counterName, long deltaValue, int sampleIntervalSeconds, + DateTime? collectionTime = null) + { + using var readLock = _duckDb.AcquireReadLock(); + var connection = await SeedConnectionAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO perfmon_stats + (collection_id, collection_time, server_id, server_name, + object_name, counter_name, cntr_value, delta_cntr_value, sample_interval_seconds) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"; + + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime ?? TestPeriodEnd }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = "SQLServer:SQL Statistics" }); + cmd.Parameters.Add(new DuckDBParameter { Value = counterName }); + cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue * 2 }); + cmd.Parameters.Add(new DuckDBParameter { Value = deltaValue }); + cmd.Parameters.Add(new DuckDBParameter { Value = sampleIntervalSeconds }); + + await cmd.ExecuteNonQueryAsync(); + } + /// /// Seeds memory_clerks with clerk type → MB mappings. /// diff --git a/Lite/Analysis/AnomalyDetector.cs b/Lite/Analysis/AnomalyDetector.cs index 30e0ce0a10..c06096799d 100644 --- a/Lite/Analysis/AnomalyDetector.cs +++ b/Lite/Analysis/AnomalyDetector.cs @@ -716,14 +716,20 @@ private async Task DetectBatchRequestAnomalies(AnalysisContext context, List= $2 AND collection_time <= $3 AND counter_name = 'Batch Requests/sec' -AND delta_cntr_value >= 0"; +AND delta_cntr_value >= 0 +AND sample_interval_seconds > 0"; cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); cmd.Parameters.Add(new DuckDBParameter { Value = context.TimeRangeStart }); diff --git a/Lite/Analysis/BaselineProvider.cs b/Lite/Analysis/BaselineProvider.cs index a517765c8c..c558e9836d 100644 --- a/Lite/Analysis/BaselineProvider.cs +++ b/Lite/Analysis/BaselineProvider.cs @@ -294,13 +294,19 @@ FROM v_cpu_utilization_stats // Cumulative counter — restart exclusion via subquery with QUALIFY. // Excludes samples where delta drops to 0 when prior sample was > 1000 // (restart signature for cumulative counters). + // #3527: v is the per-second rate — the per-interval delta divided by the row's measured + // sample_interval_seconds — so the baseline population is in the same requests/sec unit as + // the detector's window statistic. Interval <= 0 rows (unknowable delta) are skipped, never + // read as 0. The restart signature stays on the RAW delta: its > 1000 bar predates the + // division and marks a counter reset regardless of cadence. MetricNames.BatchRequests => @" WITH clean AS ( - SELECT collection_time, delta_cntr_value AS v + SELECT collection_time, delta_cntr_value * 1.0 / NULLIF(sample_interval_seconds, 0) AS v FROM v_perfmon_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 AND counter_name = 'Batch Requests/sec' AND delta_cntr_value >= 0 + AND sample_interval_seconds > 0 QUALIFY NOT (delta_cntr_value = 0 AND COALESCE(LAG(delta_cntr_value) OVER (ORDER BY collection_time), 0) > 1000) )," + RobustTierScaffold, diff --git a/Lite/Analysis/DuckDbFactCollector.Resources.cs b/Lite/Analysis/DuckDbFactCollector.Resources.cs index fbdf2975e4..32d272e438 100644 --- a/Lite/Analysis/DuckDbFactCollector.Resources.cs +++ b/Lite/Analysis/DuckDbFactCollector.Resources.cs @@ -339,6 +339,8 @@ FROM v_cpu_utilization_stats /// /// Collects key perfmon throughput counters: Batch Requests/sec, compilations, recompilations. /// Unscored context that distinguishes a busy server from a sick one (used by the AI surfaces). + /// Fact values are per-second rates: the per-interval delta divided by the row's measured + /// sample_interval_seconds (#3527); the raw delta and the divisor ride the metadata. /// private async Task CollectPerfmonFactsAsync(AnalysisContext context, List facts) { @@ -349,17 +351,23 @@ private async Task CollectPerfmonFactsAsync(AnalysisContext context, List await connection.OpenAsync(context.CancellationToken); using var cmd = connection.CreateCommand(); + /* #3527: delta_cntr_value spans one COLLECTION INTERVAL, not one second — at a 60s cadence the + raw delta is 60x the true rate. The honest rate divides by the row's MEASURED + sample_interval_seconds (#2234); interval <= 0 marks an unknowable delta (first sighting, + counter reset, gap past the policy), so those rows are filtered rather than emitted as 0 — + rn = 1 lands on the newest row a rate can honestly be derived from. */ cmd.CommandText = @" WITH latest AS ( - SELECT counter_name, cntr_value, delta_cntr_value, + SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds, ROW_NUMBER() OVER (PARTITION BY counter_name ORDER BY collection_time DESC) AS rn FROM v_perfmon_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Compilations/sec') + AND sample_interval_seconds > 0 ) -SELECT counter_name, cntr_value, delta_cntr_value +SELECT counter_name, cntr_value, delta_cntr_value, sample_interval_seconds FROM latest WHERE rn = 1"; cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); @@ -372,6 +380,7 @@ AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Com var counterName = reader.GetString(0); var cntrValue = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); var deltaValue = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)); + var intervalSeconds = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)); var (factKey, source) = counterName switch { @@ -383,8 +392,11 @@ AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Com if (factKey == null) continue; - // All remaining counters are per-second rates — use the delta. - var value = (double)deltaValue; + /* The delta spans one collection interval — divide by the measured interval for the + per-second rate (#3527). The SQL already filters interval <= 0 (unknowable delta); + this guard keeps a raw or zero value from ever escaping if that filter regresses. */ + if (intervalSeconds <= 0) continue; + var value = deltaValue / (double)intervalSeconds; facts.Add(new Fact { @@ -395,7 +407,8 @@ AND counter_name IN ('Batch Requests/sec', 'SQL Compilations/sec', 'SQL Re-Com Metadata = new Dictionary { ["cntr_value"] = cntrValue, - ["delta_cntr_value"] = deltaValue + ["delta_cntr_value"] = deltaValue, + ["sample_interval_seconds"] = intervalSeconds } }); }