fix(snmp): scale agent-polled temperatures + quiet two info logs

- 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.
This commit is contained in:
Graham McIntire 2026-05-10 14:00:18 -05:00
parent bdfb20efdf
commit 6338556945
3 changed files with 60 additions and 4 deletions

View file

@ -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")

View file

@ -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

View file

@ -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,