Commit graph

664 commits

Author SHA1 Message Date
69152e86b3
refactor: split PacketProducer.handle_cast into pattern-matched heads
The original handle_cast had nested `if`s: outer branch on demand > 0,
inner branch on buffer-over-capacity. Split into:

- handle_cast with a `when demand > 0` guard for the hot path that
  skips the buffer entirely.
- A fallback clause that computes whether the new size would overflow
  and delegates to buffer_packet/3.
- buffer_packet/3 has two clauses — one for the drop-oldest path
  (with telemetry + warning) and one for the simple append path. Both
  share the final `maybe_activate_backpressure |> reply_noemit` step.

All 19 existing PacketProducer tests pass; dialyzer stays clean.
2026-04-23 14:26:23 -05:00
c910f9a432
refactor: split if/else in Accounts.User password helpers
- do_hash_password/3: move the changeset.valid? branch out of the
  `if` into apply_hash/3, dispatched on the boolean. The bcrypt 72-byte
  cap and plaintext-scrubbing steps now live in one clear function head.
- validate_current_password/2: dispatch on valid_password?/2 via
  check_current_password/2 so the add_error branch is its own clause.

No behavior change; all 24 Accounts tests still pass and dialyzer stays
clean.
2026-04-23 14:24:29 -05:00
1d68868d27
refactor: pattern-match InfoLive.Show path parsing + add tests
- parse_path_element_with_link splits its fat `if`/or-chain into three
  focused helpers: linkable_callsign?/1 (regex + alias check),
  path_alias?/1 (four String.starts_with guards), and
  classify_path_element/2 (link vs. text dispatched on the boolean).
- display_for_element/2 chooses the callsign form with/without the
  asterisk via two function heads instead of an inline `if`.

Adds 7 tests covering the parse_path_with_links/1 public API — plain
callsigns, heard-via asterisk preservation, non-linkable aliases
(WIDE/TRACE/RELAY/TCPIP), q-constructs, whitespace trimming, and mixed
paths.
2026-04-23 14:23:16 -05:00
282d3c1ccb
refactor: pattern-match ShutdownHandler + add tests
- handle_call(:shutdown, _, state): split into a shutting_down=true
  fast-path clause and a normal initiate_shutdown clause. No more
  `if state.shutting_down` inside the callback body.
- terminate/2: extract graceful_shutdown_needed?/1 (four function
  heads on the reason tag) and maybe_graceful_drain/2 (guarded on
  state.shutting_down) so the shutdown side-effects are isolated to
  a single, pattern-matched entry point.

Adds 4 unit tests for the pure callback paths
(shutting_down?, shutdown-already-in-progress, EXIT pass-through,
rescue fallback on unavailable GenServer).
2026-04-23 14:21:15 -05:00
fc5e91479b
refactor: pattern-match CoreComponents.safe_row_id + CallsignController
- CoreComponents.safe_row_id: four function heads dispatch on the row
  shape (atom-key map, string-key map, other map fallback, non-map)
  instead of a cond with Map.has_key? checks.
- Api.V1.CallsignController.validate_callsign: check_callsign_format/2
  splits the validation success/failure branches into two clauses; the
  binary-vs-everything-else dispatch stays in validate_callsign/1.

Adds 5 integration tests for the CallsignController covering invalid,
not-found, success, case-normalization, and length-limit paths. The
test file is async:false because it shares the rate-limiter ETS with
other API tests and could otherwise trip the 100/min per-IP cap.
2026-04-23 14:18:30 -05:00
d879b54fd4
refactor: replace more cond expressions with guarded clauses
- CoordinateUtils.normalize_longitude / normalize_latitude: recursion
  and clamping move into the function head via numeric guards.
- Shared.PacketUtils.map_label: dispatch on data_type + fallback via
  multi-clause helpers (map_label_for/2, prefer_name/2) instead of a
  three-branch cond with inner `if`s.
- MapLive.DataBuilder.get_received_at: pattern-match the atom- and
  string-key packet shapes in the function head.

No behavior change; all 1567 tests and 27 property checks still pass,
dialyzer remains clean.
2026-04-23 14:12:45 -05:00
35bd098b30
refactor: more pattern matching + drop unreachable get_field_value clause
- Aprsme.Packet.put_phg_fields: dispatch on the shape of the phg value
  (map / 4-char binary / anything else) via put_phg_fields_for/2.
- Aprsme.Packet.get_field_value: remove the catch-all nil clause. After
  the put_phg_fields refactor, dialyzer can prove every call site passes
  a map and flagged the fallback as unreachable — so it's gone, and
  a future mistyped caller now fails loudly instead of silently.
- LogSanitizer.sanitize_map: extract redact_field/2 for readability.
- DeviceIdentification.pattern_matches?: match_pattern/3 dispatched on
  String.contains?/2 keeps the rescue tight around the regex path.
- InfoLive.Show: get_received_at pattern-matches on key shape;
  format_distance splits locale × range via four format_*_distance
  helpers; calculate_course pipes through normalize_bearing/1.

Adds 15 tests exercising the InfoLive.Show helpers (imperial vs. metric
formatting, cardinal bearings, haversine symmetry, Decimal inputs).
Coverage 67.43 → 67.72%.
2026-04-23 14:09:53 -05:00
ee02769d38
refactor: pattern-match in HealthCheck, EncodingUtils, and StatusLive helpers
- HealthCheck.check_health(:readiness): collapse the 3-branch cond into
  readiness_status/2 dispatched on (health_status, shutting_down?).
- HealthCheck.shutting_down?: the whereis + alive? + try/catch nest
  becomes a small pipeline of function heads (ask_shutting_down?/1 +
  ask_if_alive/2), with the catch isolated to the single place it fires.
- EncodingUtils.sanitize_string_fields and sanitize_nested_map shared a
  three-branch atom-or-string key lookup. Extract update_existing_key/3
  + first_present_key/3 + nil_aware/1 helpers; the original functions
  become two-liners.
- StatusLive.Index.format_uptime: splits the cond into
  format_uptime_parts/4 clauses with numeric guards.
- StatusLive.Index.calculate_health_score: struct-shape pattern match
  on %{connected: false} / %{uptime_seconds: s} guards replaces the cond.
- StatusLive.Index.format_time_ago: pipe the diff through
  format_seconds_ago/1 with four clauses.

Adds tests for the four public StatusLive.Index helpers
(format_uptime, format_time_ago, get_health_description, format_number).
Coverage 67.29 → 67.43%.
2026-04-23 14:02:50 -05:00
564d21bb26
refactor: pattern-match in TimeHelpers.to_datetime and PageController
- TimeHelpers.to_datetime: `cond` with match?/2 guards becomes five
  function heads dispatched by input type — DateTime passes through,
  NaiveDateTime anchors to UTC, integer is a millisecond Unix epoch,
  binary tries ISO 8601, everything else is nil.
- PageController.health: the three-branch `cond` + inner `if db_healthy`
  becomes a chain of small respond_* helpers, each pattern-matched on a
  single axis (health_status, shutting_down?, db_healthy?). db_healthy?/0
  isolates the rescue.
- format_uptime splits the cond into four format_uptime_parts/4 clauses.

Adds tests for the PageController actions (health 200 / 503-draining,
ready, sitemap.xml, .well-known/api-catalog, status.json). Coverage
66.81 → 67.29%.
2026-04-23 13:58:42 -05:00
7db78675a2
refactor: collapse nested if/case into pattern-matched helpers
- SharedPacketHandler.handle_packet_update: dispatch on filter-result,
  and use maybe_enrich/2 dispatched on the enrich? boolean instead of
  an inner `if`. lookup_device_info/1 is now a trio of function heads.
- Cluster.Topology.child_spec: build_spec/3 dispatches on
  (cluster_enabled?, topologies) tuples — no more nested `if`.
- Cluster.PacketDistributor.distribute_packet: maybe_broadcast/2
  dispatches on the leader-and-enabled check; the non-leader / disabled
  paths return :ok explicitly instead of relying on an `if` expression's
  implicit nil.

Adds tests for SharedPacketHandler (filter/enrich/match paths,
callsign_filter + callsign_and_weather_filter factories) and extends
Cluster.Topology tests to cover the nil-topology and opts-merge cases.
Coverage 66.41 → 66.81%.
2026-04-23 13:55:11 -05:00
e43107a25f
refactor(packet_processor): split cond/if into multi-clause helpers
- render_for_zoom/3: zoom ≤ 8 uses heatmap; high zoom with marker_data
  pushes to the client; high zoom + nil marker_data is a no-op. Each
  branch is now a separate function head.
- push_new_packet/3: a boolean-dispatched pair replaces the popup-open
  conditional on the push_event payload.
- prepare_packet_state: the two rebinding `if`s turn into a pipeline
  through prune_if_over_cap/1 (pattern-matched on map_size/1) and
  build_marker_data/5 with a zoom ≤ 8 short-circuit clause, plus
  maybe_tag_convert_from/3 for the replacement-marker tag.
- moved_significantly?/3: the duplicated guard check between
  dispatch_visibility and batch_dispatch collapses into one helper that
  pattern-matches on the coordinate tuple returned by CoordinateUtils.

Adds extra tests covering the new significance / stale-coords branches
and the connection card LiveComponent render paths. Coverage 65.89 → 66.41%.
2026-04-23 13:51:17 -05:00
aa0c6e6566
refactor: pattern-match in DeviceCache and DatabaseMetrics + add tests
- DeviceCache.pattern_matches?: replace a 3-branch `cond` with a
  two-clause `case` on `String.contains?(pattern, "?")`. The old `*`
  branch was dead code (it did the same exact-match compare as the
  fallback), so it's removed.
- Telemetry.DatabaseMetrics.collect_postgres_metrics: dispatch on
  environment atom via multi-clause private functions instead of
  `if Application.get_env(...) == :test`.

New tests:
- DeviceCache wildcard/exact lookup paths exercised via a seeded cache.
- MobileUserSocket connect/3 for x_headers/peer_data/empty info and
  rate-limit deny.
- DatabaseMetrics pool-metrics emission captured via :telemetry handler
  and the :test-env short-circuit.
2026-04-23 13:46:00 -05:00
c5a82b77c1
refactor: pattern-match over conditionals in Navigation and InsertOptimizer
Swaps `if`/`cond` branches for multi-clause function heads or head-pattern
matching, making the control flow visible in the function signatures:

- Navigation.determine_map_location: resolve_location/4 now dispatches on
  the shape of the geolocation input and whether URL params are explicit.
- Navigation.handle_callsign_tracking: per-case clauses for empty callsign
  and explicit-URL-params short-circuits, plus a final clause that does the
  DB lookup and rescues once.
- Navigation.handle_callsign_search: dispatch_callsign_search/3 separates
  the valid/invalid callsign branches.
- InsertOptimizer.optimize_based_on_performance: pattern-match on
  [_, _, _ | _] to require ≥3 samples without calling length/1.
- InsertOptimizer.calculate_insert_options and throughput helpers split
  into guarded clauses instead of inline `if`.
- maybe_emit_optimization uses same-var pattern matching to skip the
  telemetry hit when nothing changed.

Adds unit tests covering every branch of both modules plus SignalHandler,
LocaleHook, and the InsertOptimizer's :noproc fallback path. Coverage
64.69% → 65.72%.
2026-04-23 13:38:24 -05:00
1ad1bef59f
fix(devices): make upsert_devices/1 work with Repo.insert_all
Three related bugs prevented upsert_devices/1 from ever succeeding in
production:

- The JSON map keys ("identifier", "vendor", etc.) were passed through
  to Repo.insert_all, which only accepts atom field names.
- updated_at was a %DateTime{} but the Devices schema uses :naive_datetime.
- inserted_at was never set, violating the NOT NULL constraint.

Fix by whitelisting known string keys and converting them to atoms via
String.to_existing_atom/1 (safe against atom exhaustion), using
NaiveDateTime.utc_now/1, and setting both timestamps explicitly.
2026-04-23 13:31:49 -05:00
5ef97adaf7
refactor: promote hot-path GenServer state maps to structs
Converts the inline state maps in Is, PacketConsumer, LeaderElection, and
CircuitBreaker into structs with @type t specs. Enforced keys catch typos
on state updates and give dialyzer a concrete type to check against
instead of map() in every handle_* clause.

Is' nested login_params and packet_stats are extracted to their own
modules (Aprsme.Is.LoginParams, Aprsme.Is.PacketStats) rather than
defined inline, per the project's no-nested-modules rule.
2026-04-21 10:26:20 -05:00
256e61ec76
types: add @type t to last two untyped GenServer structs
Every other defstruct in the app already has a @type t declaration;
these two GenServer states were the last holdouts. Declaring t here
means the whole codebase is ready for the typed-struct milestone of
Elixir's set-theoretic type system (the next phase after the inference
pass already shipping in 1.19).
2026-04-21 10:18:26 -05:00
f225f57044
perf(packets): keyset-based pagination for recent-packet queries
get_recent_packets/1 and get_recent_packets_for_map/1 now accept a
:cursor option (%{received_at: DateTime, id: uuid}) instead of :offset
for deep paging. On the 100M-row partitioned packets table, offset
scanning was linear in page depth; keyset is constant.

Ordering tightened to (received_at DESC, id DESC) so the cursor is
stable across ties. idx_packets_received_desc still covers the lookup;
a dedicated (received_at DESC, id DESC) index isn't worth its size
given microsecond timestamp granularity.

historical_loader migrated from batch_offset*batch_size scheduling to
a sequential cursor chain. The old loader fanned out N Process.send_after
batches up-front which could arrive out-of-order; the cursor version
waits for batch N before scheduling N+1, and also short-circuits the
remaining schedule when an under-full batch signals exhaustion.

mobile_channel load_historical_packets dropped its meaningless
`offset: 0`; callsign history already lacked offset.

No LiveView or WebSocket payload shapes changed. offset: 0 (or omitted)
still behaves as first page; non-zero offsets are silently ignored.
2026-04-21 10:15:04 -05:00
b8a9b8a465
chore(dialyzer): enable stricter flags and fix 97 resulting findings
Turned on :error_handling, :underspecs, and :unmatched_returns in
mix.exs dialyzer config. The 97 warnings this surfaced were fixed in
place rather than suppressed:

- unmatched_return (79): explicit discard with `_ = ...` for
  fire-and-forget side effects (Process.cancel_timer, :ets.new,
  send/2), and pattern-matched `:ok = ...` for control-plane
  Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future
  return-shape change fails loud.

- contract_supertype (18): tightened @spec arg and return types on
  data_builder, historical_loader, url_params, packet_utils,
  encoding_utils, aprs_symbol, weather_controller, packet_replay to
  match each function's actual success typing.

No behavioural change. mix compile clean, 1008 tests pass, dialyzer
count is now 0.
2026-04-21 10:07:01 -05:00
8a0381d9bd
perf(regex_cache): replace half-wipe eviction with LRU
Stored entries now carry a monotonic access counter refreshed on every
hit. When the cache fills, the 10% least-recently-used entries are
dropped instead of an arbitrary half of the table, which previously
caused thrashing under diverse input by evicting hot entries.
2026-04-21 09:40:20 -05:00
09f92c6738
feat(mobile): rate-limit socket + batch historical packet delivery
- MobileUserSocket: cap new socket connections per IP at 30/min via
  Aprsme.RateLimiter. Denials log and return :error at handshake.
  Tests bypass the check (no real peer_data).

- MobileChannel: rate-limit the expensive client-initiated handlers
  (subscribe_bounds, subscribe_callsign, search_callsign) at
  30/minute per socket. Streaming packet delivery is unaffected.

- MobileChannel: historical packet loads now arrive as a batched
  `packets` event (100 per frame) instead of one `packet` frame per
  row. Live streaming still uses the single `packet` event.

- Updated docs/mobile-api.md with the `packets` event and rate-limit
  semantics; migration note preserves compatibility for clients that
  only handle live `packet` events.
2026-04-21 09:38:51 -05:00
c64ad4d571
fix(data_builder): clear weather cache in after block
If build_packet_data raises, the :weather_callsigns_cache process-dict
entry would stick around and poison subsequent unrelated calls on the
same LiveView process. Wrap the building pass in try/after so the key
is always removed.
2026-04-21 09:32:17 -05:00
2fc4706d3d
fix: stop silent packet loss, timer leak, and seq-scans
- packet_consumer: when a batch exceeds max_batch_size, carry the
  excess over to the next cycle instead of dropping it. Previously
  any overage was split off into a drop list that was only logged,
  losing packets the producer had already acknowledged.

- map_live: cancel the hover_end_timer in terminate/2 so a user
  disconnecting while a marker is still highlighted doesn't leave
  an orphaned timer behind.

- packet_consumer: rescue/log exceptions inside the async broadcast
  task so PubSub serialization bugs or outages are observable
  instead of silently killing the Task.

- prepared_queries: swap ST_Y/ST_X BETWEEN predicates for the && /
  ST_MakeEnvelope pattern so bounds queries hit the GIST index on
  packets.location instead of sequentially scanning.
2026-04-21 09:29:06 -05:00
31ea64aa6c
perf: reduce per-packet allocations in ingest pipeline
Drop the pre-insert aprs_messages broadcast in Is.dispatch (subscribers
already get a richer payload via postgres:aprsme_packets after insert).
Switch PacketsLive.CallsignView to the per-callsign packets:<CS> topic
so it only receives relevant packets instead of filtering every one.

In PacketConsumer: reuse the received_at stamped in Is.dispatch instead
of calling DateTime.utc_now/0 per packet; drop the duplicate struct_to_map
pass (Is.dispatch already handled it); fold coordinate validation and
Geo.Point construction into set_lat_lon + create_location_geometry so
coords are normalized once; extract device_identifier from the already-
normalized attrs; pre-build the broadcast payload once (identifier pick,
lat/lon aliases, routing callsign) so the async broadcast task no longer
does Map.drop/Map.merge per packet.

Rewrite PacketSanitizer.sanitize_packet / sanitize_data_map with Map.new/2
instead of Enum.reduce + Map.put — one allocation per packet instead of N.

Also compute has_weather in Packet.changeset/2 so direct-changeset inserts
(tests, backfills) populate it the same way the GenStage pipeline does.
2026-04-19 13:33:16 -05:00
5ab2ffc8f3
perf: replace per-row pg_notify trigger with elixir broadcast after batch insert 2026-04-17 13:46:51 -05:00
1788419ae0
perf: drop redundant packet indexes, compute has_weather in elixir, tighten on_conflict target 2026-04-17 13:42:19 -05:00
4854157ee9
feat: add sitemap.xml, api-catalog, and agent-discovery Link headers 2026-04-17 13:31:21 -05:00
0b178d45ba
fix: unsubscribe from pubsub topics on LiveView terminate; catch-all handle_info in ConnectionManager 2026-04-17 13:24:58 -05:00
ab29a3dca5
fix: add handle_info/2 for distributed_packet messages in PacketDistributor 2026-04-17 13:16:46 -05:00
0c50208e4f
fix: use string interpolation for SET statement_timeout instead of parameterized query 2026-04-17 13:08:25 -05:00
adaf9599e2
chore: update dependencies and fix pre-existing issues
Dep upgrades (mix deps.update --all):
- phoenix_live_view 1.1.27 → 1.1.28
- swoosh 1.23.1 → 1.25.0
- bandit 1.10.3 → 1.10.4
- credo 1.7.17 → 1.7.18
- hammer 7.2.0 → 7.3.0
- igniter 0.7.6 → 0.7.9
- and minor: fine, lazy_html, meck, mimerl
- removed stale lock entries: geocalc, gettext_pseudolocalize, gridsquare

Bug fixes surfaced during update:
- ETS cache tables were :protected (owned by Application master), preventing
  the Cache GenServer from writing; change to :public + write_concurrency
  so device/symbol/query caches actually work
- historical_dot_html returned Phoenix.HTML.safe tuple instead of plain string;
  symbol_html is JSON-encoded for JS so it must be binary
- String.slice always returns binary so the || "Unknown error" fallback was
  unreachable dead code (dialyzer guard_fail)
- Supervisor.which_children always returns a list so the _ -> branch in
  database_metrics was unreachable dead code (dialyzer pattern_match_cov)
- Add @type t :: %__MODULE__{} to User schema to resolve unknown_type in user_auth spec
- Extract nested if in leader_election to fix Credo nesting depth violation
- Update .dialyzer_ignore.exs: remove 2 stale entries, add false positives for
  Ecto.Multi/Gettext opaque types and Mix.Task PLT limitations
2026-04-15 14:07:29 -05:00
Claude
51d5107983
fix: inline regex module attributes to fix deprecation warning
Elixir 1.19 deprecates storing regexes in module attributes. Inline
@wx_preamble_pattern and @wx_field_patterns directly into their
respective functions to resolve the --warnings-as-errors CI failure.

https://claude.ai/code/session_01Ps7Zq3wiBur1RtRKN7JM6X
2026-03-27 16:18:27 -05:00
Claude
a8051ce247
fix: code review refinements
- RegexCache: change ETS table from :public to :protected for consistency
  with other tables, preventing uncontrolled writes bypassing GenServer
- MobileChannel: ensure_float now returns nil instead of the original
  unparsed string on parse failure, so validate_bounds properly rejects it
- map.ts: remove dead commented-out localStorage code
- map.ts: use instanceof HTMLAnchorElement instead of unsafe type cast
  in popup navigation handler
- TrailManager: add destroyed flag to prevent stale RAF and debounce
  callbacks from firing after destroy, and clean up hover timer on destroy

https://claude.ai/code/session_01Ps7Zq3wiBur1RtRKN7JM6X
2026-03-27 16:18:27 -05:00
2300dcd69c
fix: stabilize moving station tracking 2026-03-26 18:48:43 -05:00
bc82777d34
fix: close review findings 2026-03-26 12:36:50 -05:00
288b9fbbb2
fix: address security vulnerabilities and concurrency issues
- Fix SQL injection in partition_manager, db_optimizer, and release.ex
- Fix XSS vulnerabilities with proper HTML escaping in LiveViews
- Add proper error handling for email delivery functions
- Fix race conditions with advisory locks and atomic operations
- Replace unsupervised spawn/Task.start with supervised alternatives
- Convert ETS operations to GenServer serialization for thread safety
- Change ETS tables from :public to :protected access
- Add client limits to prevent unbounded memory growth
- Add PubSub cleanup in GenServer terminate callbacks
- Fix device upsert to use atomic Repo.insert_all
2026-03-23 16:59:09 -05:00
d234c313ca
fix: harden mobile channel numeric params 2026-03-22 17:36:09 -05:00
35962c195b
fix: guard device cache refresh when stopped 2026-03-22 17:31:54 -05:00
21d28afe8e
refactor: reduce map live complexity 2026-03-22 17:20:47 -05:00
f840e8fd34
fix: guard APRS send_message when disconnected 2026-03-22 17:16:44 -05:00
4d45c8351a
fix: guard pubsub APIs when not running 2026-03-22 17:14:24 -05:00
7c0861f216
fix: replace duplicate spatial registrations 2026-03-22 17:12:20 -05:00
1631352786
fix: guard connection monitor stats lookup 2026-03-22 17:09:07 -05:00
4847def0d8
Add referrer meta tag to fix OSM tile 403 errors
OSM tile servers require a Referer header. Cloudflare appears to be
stripping the Referrer-Policy HTTP header, so add a meta tag that
the browser will always respect.
2026-03-22 16:34:32 -05:00
a9cf2d3eb8
Add logging to map location determination for debugging 2026-03-22 16:27:43 -05:00
c769d89264
fix: stabilize packet broadcast tests 2026-03-22 16:17:55 -05:00
139568cd05
Log Cloudflare geolocation headers on each request 2026-03-22 16:08:52 -05:00
6988116a42
Fix Cloudflare IP geolocation not reaching LiveView on page load
The IPGeolocation plug was setting session data, but the live_session
wasn't forwarding it to the LiveView mount. Also removed the root-path
restriction so geolocation works on any initial page load.
2026-03-22 16:08:00 -05:00
8937c1fb57
fix: handle APRS status DB ownership exits 2026-03-22 16:02:03 -05:00
0d4314b880
Fix object positioning to use object name instead of origin callsign
Objects and items were being displayed at the origin station's location
instead of their actual coordinates because the broadcast used the
sender (origin callsign) as the identifier instead of object_name/item_name.

For example, object CALGRY sent by VE6RWB-15 was appearing at VE6RWB-15's
location instead of CALGRY's actual coordinates.

Changes:
- Modified broadcast_single_packet to use object_name for objects
  and item_name for items as the identifier instead of sender
- Added tests verifying objects and items broadcast with correct identifier
- Objects/items now display at their specified coordinates, not origin station

Fixes the positioning bug where objects appeared at sender location.
2026-03-22 15:19:52 -05:00
0c9087b7f1
Add email notifications for production errors
Implements error email notifications that:
- Send email only for first occurrence of each unique error
- Only send in production environment (dev/test are silent)
- Include error details, location, and link to ErrorTracker UI
- Use existing Resend/Swoosh configuration for delivery
- Email sent to graham@mcintire.me

Integrates with ErrorTracker via telemetry events.
2026-03-22 15:12:59 -05:00