From 6338556945b2909a12b121105a38cb400388352a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 10 May 2026 14:00:18 -0500 Subject: [PATCH] fix(snmp): scale agent-polled temperatures + quiet two info logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ToweropsWeb.AgentChannel now runs sensor readings through Towerops.Snmp.SensorScale.normalize/2 in both resolve_sensor_value/2 and the GraphQL publisher. Without this, MikroTik mtxrHl* deci-degree readings (e.g. Culleoka reporting 294 -> 29.4°C) were stored and alerted on at 294°C. The DevicePollerWorker path already did this; only the agent path was missing it. - Demote "Received SNMP result from agent" and "Processing polling result for X" from info to debug — they fire every poll cycle on every device and were dominating prod logs. --- CHANGELOG.txt | 17 +++++++++++ lib/towerops_web/channels/agent_channel.ex | 17 ++++++++--- .../channels/agent_channel_test.exs | 30 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index ae21d077..99aac8c4 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,20 @@ +2026-05-10 +fix(snmp): apply SensorScale on the agent-polled path + ToweropsWeb.AgentChannel.resolve_sensor_value/2 and the GraphQL + publisher now run readings through Towerops.Snmp.SensorScale.normalize/2 + before storing/publishing. Previously only the direct DevicePollerWorker + path normalized values, so MikroTik mtxrHl* deci-degree readings (e.g. + Culleoka router reporting 294 → expected 29.4 °C) were stored and + alerted on as 294 °C. + Test: agent_channel_test.exs gains "poll result rescales implausibly + hot temperature readings" — pushing 294 lands as 29.4 in last_value. + +chore(logs): demote two noisy info → debug + AgentChannel "Received SNMP result from agent" and + "Processing polling result for X" now log at debug. They fire on + every successful agent poll cycle and were dominating production + log volume. + 2026-05-10 feat(insights): superuser "Regenerate insights" button on /insights ToweropsWeb.InsightsLive.Index — new handle_event("regenerate_insights") diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 8fd54ff3..744f2173 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -52,6 +52,7 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Snmp.AgentDiscovery alias Towerops.Snmp.Discovery alias Towerops.Snmp.SensorChangeDetector + alias Towerops.Snmp.SensorScale alias Towerops.Topology alias ToweropsWeb.GraphQL.Subscriptions alias ToweropsWeb.RemoteIp @@ -705,7 +706,7 @@ defmodule ToweropsWeb.AgentChannel do # Private helpers defp process_snmp_result_message(binary_b64, socket) do - Logger.info("Received SNMP result from agent (binary size: #{byte_size(binary_b64)})") + Logger.debug("Received SNMP result from agent (binary size: #{byte_size(binary_b64)})") with {:ok, binary} <- safe_base64_decode(binary_b64), {:ok, result} <- SnmpResult.decode(binary) do @@ -1932,7 +1933,7 @@ defmodule ToweropsWeb.AgentChannel do # Use server time to avoid clock drift issues between agents timestamp = Towerops.Time.now() - Logger.info( + Logger.debug( "Processing polling result for #{device.name}: #{map_size(oid_values)} OIDs, #{length(snmp_device.sensors)} sensors, #{length(snmp_device.interfaces)} interfaces" ) @@ -2140,7 +2141,13 @@ defmodule ToweropsWeb.AgentChannel do normalized_oid = String.trim_leading(sensor.sensor_oid, ".") raw_value = Map.get(oid_values, normalized_oid) parsed = parse_float(raw_value) - value = if parsed, do: Float.round(parsed / sensor.sensor_divisor, 1) + + value = + if parsed do + divided = parsed / sensor.sensor_divisor + scaled = SensorScale.normalize(sensor.sensor_type, divided) + Float.round(scaled / 1.0, 2) + end %{ sensor_id: sensor.id, @@ -2168,7 +2175,9 @@ defmodule ToweropsWeb.AgentChannel do with value when not is_nil(value) <- Map.get(oid_values, normalized_oid), parsed when not is_nil(parsed) <- parse_float(value) do - Float.round(parsed / sensor.sensor_divisor, 1) + divided = parsed / sensor.sensor_divisor + normalized = SensorScale.normalize(sensor.sensor_type, divided) + Float.round(normalized / 1.0, 2) else _ -> nil end diff --git a/test/towerops_web/channels/agent_channel_test.exs b/test/towerops_web/channels/agent_channel_test.exs index 26018b18..6f1e1aa6 100644 --- a/test/towerops_web/channels/agent_channel_test.exs +++ b/test/towerops_web/channels/agent_channel_test.exs @@ -993,6 +993,36 @@ defmodule ToweropsWeb.AgentChannelTest do assert updated_sensor.last_value == 45.0 end + test "poll result rescales implausibly hot temperature readings", %{ + socket: socket, + device: device, + sensor: sensor + } do + # Some MikroTik models (e.g. RB-series with mtxrHl* OIDs) report + # temperature in deci-degrees so a "29.4°C" reading lands on the + # wire as 294. SensorScale.normalize/2 must run on the agent path + # so it doesn't store 294°C. + result = %SnmpResult{ + device_id: device.id, + job_type: :POLL, + job_id: "poll:#{device.id}", + timestamp: DateTime.to_unix(DateTime.utc_now()), + oid_values: %{ + sensor.sensor_oid => "294" + } + } + + push(socket, "result", encode_payload(result)) + + updated_sensor = + poll_until(fn -> + s = Towerops.Repo.get!(Sensor, sensor.id) + if s.last_value && s.last_value < 100.0, do: s + end) + + assert_in_delta updated_sensor.last_value, 29.4, 0.01 + end + test "poll result stores interface stats", %{ socket: socket, device: device,