Telemetry integration for the EtherCAT stack.
Emits events via :telemetry and provides lightweight atomic counters
for IEx inspection.
Introspection Strategy
Telemetry is the machine-readable surface:
- event names are stable and enumerable through
events/0 - measurements are numeric
- metadata is intentionally low-cardinality and bounded
- detailed failure terms stay in logs and API replies, not telemetry
- the
[:ethercat, :bus, :transact, :exception]span event is the one intentional exception and keeps the raw exception payload from:telemetry.span/3
Logs are the human-readable narrative:
infofor lifecycle transitions and successful recoverywarningfor degraded but recoverable runtime conditionserrorfor crashes and session-stopping faultsdebugfor step-by-step progress and repeated retry chatter
This split keeps dashboards and alerts predictable while preserving rich operator context in the logs.
Events
Transaction span (emitted by Bus.transaction/2|3)
[:ethercat, :bus, :transact, :start]
measurements: %{system_time: integer(), monotonic_time: integer()}
metadata: %{datagram_count: integer(), class: :realtime | :reliable}
[:ethercat, :bus, :transact, :stop]
measurements: %{duration: integer()}
metadata: %{datagram_count: integer(), total_wkc: integer(), class: :realtime | :reliable, status: :ok | :error, error_kind: atom() | nil}
[:ethercat, :bus, :transact, :exception]
measurements: %{duration: integer()}
metadata: %{kind: atom(), reason: term(), stacktrace: list()}Submission events
[:ethercat, :bus, :submission, :enqueued]
measurements: %{queue_depth: non_neg_integer()}
metadata: %{link: String.t(), class: :realtime | :reliable, state: :idle | :awaiting}queue_depth is the depth of that submission class immediately after
admission and before any immediate dispatch on an idle bus.
[:ethercat, :bus, :submission, :expired]
measurements: %{age_us: non_neg_integer()}
metadata: %{link: String.t(), class: :realtime}Dispatch events
[:ethercat, :bus, :dispatch, :sent]
measurements: %{transaction_count: pos_integer(), datagram_count: pos_integer()}
metadata: %{link: String.t(), class: :realtime | :reliable}Frame events
[:ethercat, :bus, :frame, :sent]
measurements: %{size: integer(), tx_timestamp: integer() | nil}
metadata: %{link: String.t(), endpoint: String.t(), port: :primary | :secondary}
[:ethercat, :bus, :frame, :received]
measurements: %{size: integer(), rx_timestamp: integer() | nil}
metadata: %{link: String.t(), endpoint: String.t(), port: :primary | :secondary}
[:ethercat, :bus, :frame, :dropped]
measurements: %{size: integer()}
metadata: %{link: String.t(), reason: atom()}Link lifecycle events
[:ethercat, :bus, :link, :down]
measurements: %{}
metadata: %{link: String.t(), endpoint: String.t(), reason: atom()}
[:ethercat, :bus, :link, :reconnected]
measurements: %{}
metadata: %{link: String.t(), endpoint: String.t()}
[:ethercat, :bus, :link, :health, :changed]
measurements: %{}
metadata: %{link: String.t(), port: :primary | :secondary, from: term(), to: term()}
[:ethercat, :bus, :link, :exchange, :timeout]
measurements: %{arrival_count: non_neg_integer(), consecutive_timeouts: pos_integer()}
metadata: %{link: String.t(), detail: :no_arrivals | :partial_arrivals, arrival_classes: [atom()], pri_sent: boolean(), sec_sent: boolean()}DC maintenance and lock monitoring
[:ethercat, :dc, :tick]
measurements: %{wkc: integer()}
metadata: %{ref_station: non_neg_integer()}
[:ethercat, :dc, :sync_diff, :observed]
measurements: %{max_sync_diff_ns: non_neg_integer()}
metadata: %{ref_station: non_neg_integer(), station_count: pos_integer()}
[:ethercat, :dc, :lock, :changed]
measurements: %{}
metadata: %{ref_station: non_neg_integer(), from: atom(), to: atom(), max_sync_diff_ns: non_neg_integer() | nil}
[:ethercat, :dc, :runtime, :state, :changed]
measurements: %{}
metadata: %{from: :healthy | :failing, to: :healthy | :failing, reason: atom() | nil, consecutive_failures: non_neg_integer()}Domain cycle events
[:ethercat, :domain, :cycle, :done]
measurements: %{duration_us: integer(), cycle_count: non_neg_integer(), completed_at_us: integer()}
metadata: %{domain: atom()}
[:ethercat, :domain, :cycle, :invalid]
measurements: %{total_invalid_count: pos_integer(), invalid_at_us: integer()}
metadata: %{domain: atom(), reason: atom(), expected_wkc: non_neg_integer() | nil, actual_wkc: non_neg_integer() | nil, reply_count: non_neg_integer() | nil}
[:ethercat, :domain, :cycle, :transport_miss]
measurements: %{consecutive_miss_count: pos_integer(), total_invalid_count: pos_integer(), invalid_at_us: integer()}
metadata: %{domain: atom(), reason: atom(), expected_wkc: non_neg_integer() | nil, actual_wkc: non_neg_integer() | nil, reply_count: non_neg_integer() | nil}Domain fault events
[:ethercat, :domain, :stopped]
measurements: %{}
metadata: %{domain: atom(), reason: atom()}
[:ethercat, :domain, :crashed]
measurements: %{}
metadata: %{domain: atom(), reason: atom()}Master lifecycle events
[:ethercat, :master, :state, :changed]
measurements: %{}
metadata: %{from: atom(), to: atom(), public_state: atom(), runtime_target: atom()}
[:ethercat, :master, :startup, :bus_stable]
measurements: %{slave_count: non_neg_integer()}
metadata: %{}
[:ethercat, :master, :configuration, :result]
measurements: %{duration_ms: non_neg_integer()}
metadata: %{status: :ok | :error, slave_count: non_neg_integer(), runtime_target: atom(), reason: atom() | nil}
[:ethercat, :master, :activation, :result]
measurements: %{duration_ms: non_neg_integer()}
metadata: %{status: :ok | :blocked | :error, runtime_target: atom(), blocked_count: non_neg_integer(), reason: atom() | nil}
[:ethercat, :master, :dc_lock, :decision]
measurements: %{}
metadata: %{transition: :lost | :regained, policy: atom(), outcome: atom(), lock_state: atom(), max_sync_diff_ns: non_neg_integer() | nil}
[:ethercat, :master, :slave_fault, :changed]
measurements: %{}
metadata: %{slave: atom(), from: atom() | nil, to: atom() | nil, from_detail: atom() | nil, to_detail: atom() | nil}Slave fault events
[:ethercat, :slave, :crashed]
measurements: %{}
metadata: %{slave: atom(), reason: atom()}
[:ethercat, :slave, :health, :fault]
measurements: %{al_state: 1 | 2 | 4 | 8, error_code: non_neg_integer()}
metadata: %{slave: atom(), station: non_neg_integer()}
[:ethercat, :slave, :down]
measurements: %{}
metadata: %{slave: atom(), station: non_neg_integer(), reason: atom()}
[:ethercat, :slave, :startup, :retry]
measurements: %{retry_count: pos_integer(), retry_delay_ms: pos_integer()}
metadata: %{slave: atom(), station: non_neg_integer(), phase: atom(), reason: atom()}Timestamps
tx_timestamp and rx_timestamp are System.monotonic_time/0 values.
When Linux SO_TIMESTAMPING is available, rx_timestamp is derived from
the kernel's software RX timestamp for nanosecond accuracy. Their difference
gives wire-level round-trip time.
Example
:telemetry.attach_many("ethercat-log", [
[:ethercat, :bus, :transact, :stop],
[:ethercat, :bus, :link, :down]
], &MyHandler.handle_event/4, nil)
Summary
Functions
Attach counters to all EtherCAT telemetry events.
Use stats/0 to print current counts and reset/0 to zero them.
Example
EtherCAT.Telemetry.attach()
# ... do some work ...
EtherCAT.Telemetry.stats()
EtherCAT.Telemetry.reset()
Detach counters.
@spec events() :: [[atom()]]
Return the canonical list of public EtherCAT telemetry events.
This is the supported source of truth for external consumers that want to
subscribe via :telemetry.attach_many/4 without duplicating event names.
Example
:ok =
:telemetry.attach_many(
"ethercat-dashboard",
EtherCAT.Telemetry.events(),
&MyDashboard.handle_event/4,
nil
)
Reset all counters to zero.
@spec snapshot() :: [{[atom()], non_neg_integer()}]
Return current counters as {event, count} tuples.
Print event counts.