fix: bound and throttle mobile channel logging #26
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/w9-mobile-channel-logging"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes W9 from bugs.md.
inspect(payload)logs removed entirely — handlers log only the event name, so hostile frames can neither flood logs nor inject log contentdebugand moved inside the rate limiter, so denied frames cost no log lineunsubscribeandunsubscribe_callsignnow draw on a dedicated generous bucket (120/min), bounding the unthrottled SpatialPubSub churnVerification: focused channel tests passed (62), full suite passed (2237),
mix credo --strict, and commit hooks including Dialyzer passed.🤖 Skippy PR review
2 findings — 1 blocking before merge.
lib/aprsme_web/channels/mobile_channel.ex:154lib/aprsme_web/channels/mobile_channel.ex:71First review, full diff read (2 files, +76/-34); read-only, nothing executed.
Reviewed
e8f914fac673. Commentskippy reviewto re-run.@ -68,0 +68,4 @@# Unsubscribes are cheap but touch the SpatialPubSub GenServer; a generous# dedicated bucket keeps a spamming client from monopolising it while never# inconveniencing a real one.@unsubscribe_limit 120🔵 Suggestion — Unsubscribe bucket is per-IP, and burns a token when there is nothing to release
@unsubscribe_limitis enforced throughcheck_rate_limit/3, whose key is"mobile_channel:<op>:<peer_ip>"(~line 393) — the bucket is per IP, not per socket, despite the "Per-socket rate limits" docstring. So the "never inconveniencing a real one" 120/min is shared by every client behind one NAT/CGNAT address (andunsubscribealso charges a token whensubscribedis already false, so an idle client retrying burns the shared budget it later needs). When it does bite, the client gets a hard:errorreply from a handler that previously always replied:ok, and a client that doesn't retry keeps its SpatialPubSub registration and packet stream alive until the socket closes.Cheapest fix: move
check_rate_limitinside theif socket.assigns[:subscribed] && socket.assigns[:bounds]/if socket.assigns[:tracked_callsign]branches so only real releases are throttled, and consider keying on the channel process rather than the peer IP.@ -145,2 +153,2 @@def handle_in("unsubscribe", payload, socket) doLogger.info("Mobile websocket received unsubscribe: #{inspect(payload)}")def handle_in("unsubscribe", _payload, socket) dowith :ok <- check_rate_limit(socket, "unsubscribe", @unsubscribe_limit) do🟡 Warning — Denied frames still cost a log line — one per frame, unbounded
The comment above
subscribe_boundssays a denied frame costs no log line, but the deny branch ofcheck_rate_limit/3(this file, ~line 401) logsLogger.warning("Mobile channel rate-limited op=...")on every denial with no dedupe or sampling. Before this patchunsubscribehad no limiter, so those frames were silent; now a client loopingpush("unsubscribe", %{})gets 120 allowed frames and then one warning line per frame at line rate — exactly the log-flood W9 was meant to close.config/prod.exsrunslevel: :info, so demoting the receive logs bought nothing on this path: allow = silent, deny = one line per frame forever.Fix: keep the reply, lose the per-frame line — e.g.
Logger.debugin the deny branch plus a:telemetry.execute([:aprsme, :mobile_channel, :rate_limited], %{count: 1}, %{op: op})counter, or log only on the transition into denial.🤖 Skippy PR review
1 finding — 1 blocking before merge.
lib/aprsme_web/channels/mobile_channel.ex:158Resolved both earlier findings (deny branch no longer logs per frame; unsubscribe only charges when it releases). The reorder is correct, but it leaves this PR's own unsubscribe tests asserting behaviour that no longer exists, so the suite is red at this head.
Reviewed
23fbcd25b5b0. Commentskippy reviewto re-run.@ -148,3 +157,2 @@if socket.assigns[:subscribed] && socket.assigns[:bounds] doAprsme.SpatialPubSub.unregister_client(socket.assigns.client_id)Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "spatial:subscriber:#{socket.assigns.client_id}")with :ok <- check_rate_limit(socket, "unsubscribe", @unsubscribe_limit) do🟠 High — Charge-only-on-release breaks both unsubscribe regression tests (suite is red at this head)
The reorder itself is correct, but it invalidates the two regression tests this PR added, and this commit did not touch the test file, so
test/aprsme_web/channels/mobile_channel_test.exsfails at23fbcd25.Test at
test/aprsme_web/channels/mobile_channel_test.exs:931-934(unsubscribe): aftersubscribe_boundsplus one realunsubscribe,subscribedisfalse, so the 120 pushes in the loop all take theelsebranch (line 171), charge nothing, and reply{:ok, %{message: "Not subscribed"}}. The finalassert_reply refused, :errorcan never be satisfied.Test at
:938-943(unsubscribe_callsign):join/3(line 102) never setstracked_callsign, so all 121 pushes hit theelsebranch and the 122nd still replies{:ok, %{message: "Not tracking any callsign"}}instead of the asserted:error.Both passed at
e8f914faonly because the pre-reordercheck_rate_limitran before theif, i.e. the tests assert exactly the behaviour this commit removed.Fix: charge the bucket directly instead of trying to exhaust it through frames (the setup already hands you
peer_ip, line 61):Driving it through frames cannot work regardless:
subscribe_boundsis capped at 30/min, so you cannot re-arm 121 times inside one window. Worth adding the complementary assertion that an idle retry is free (manyunsubscribepushes with nothing subscribed all reply:ok). The PR's "62 focused tests passed" predates this commit.23fbcd25b58dbf67abc1🤖 Skippy PR review
1 finding — none blocking.
lib/aprsme_web/channels/mobile_channel.ex:431Resolved the earlier High: both unsubscribe regression tests are rewritten at
8dbf67abto charge the bucket directly (see the newexhaust_rate_limit/2helper) and now assert the charge-only-on-release rule instead of the removed behaviour. Branch was rebased, so this re-read the full PR diff.Reviewed
8dbf67abc1b5. Commentskippy reviewto re-run.@ -412,0 +428,4 @@Logger.debug("Mobile channel rate-limited op=#{op} client=#{inspect(self())}"):telemetry.execute([:aprsme, :mobile_channel, :rate_limited],🔵 Suggestion — The replacement signal for the silenced deny log is a no-op in prod
The deny branch now logs at
debug(line 428) andconfig/prod.exs:39runslevel: :info, so the replacement signal is[:aprsme, :mobile_channel, :rate_limited](line 431) - and nothing in the app is attached to it. It is the only[:aprsme, ...]event emitted without a PromEx counter:payload_metrics/0registers[:aprsme, :payload, :rejected],mobile_metrics/0registers[:aprsme, :mobile, :buffer_overflow], and this event appears nowhere but this line.:telemetry.execute/3with no handler is a no-op, so a refused frame now costs no log line and no metric, and the first sign of a client stuck against the per-IP bucket (shared behind CGNAT) is its own error reply - including the new hard:erroronunsubscribe/unsubscribe_callsign. Fix: register it next tomobile_metrics/0inlib/aprsme/prom_ex/plugins/aprsme.ex, which already has the shape:8dbf67abc194b38af6cdResolved 1 of 1 open finding:
[:aprsme, :mobile_channel, :rate_limited]is now registered as a PromEx counter with bounded[:op]tags inmobile_metrics/0(lib/aprsme/prom_ex/plugins/aprsme.ex:199), so the silenced deny path has a prod-visible signal again, and the new plugin test asserts the metric name.Branch was rebased, so this re-read the full PR diff rather than an incremental range: beyond that counter and its test, nothing changed from
8dbf67ab. No new findings, nothing left open.skippy review