# `DpExchange.Core.Telemetry`
[🔗](https://github.com/DistortionPoint/dp-exchange-core/blob/v0.3.116/lib/dp_exchange/core/telemetry.ex#L1)

Standardised `:telemetry` event names, emitted through the functions below. A consumer
attaches to these names without coupling to any particular venue's implementation —
which is what makes one metrics dashboard work across the whole family.

Telemetry is the **metrics** channel: high-frequency, individually unimportant,
aggregate-only, and lossy by nature. A condition a consumer must act on is not a
metric and does not belong here.

## Why the category is `:link` and not `:ws`

These events were named `[:dp_exchange, :ws, …]`. That names a **transport**, and
under the facade contract no venue's transport is a consumer's concern — a venue
streaming over MQTT or long-polling has no "ws" to report, so it would either emit a
lie or emit nothing and look permanently disconnected.

The category is the venue **link**: the fact that there is a live route to the venue,
and whether it is up. What carries that route is package-internal.

## These names were documented for a long time before anything emitted them

This module said, in its first line, that these are the events "every venue package
emits". None of them did. There was not one `:telemetry.execute/3` call anywhere in the
family — not in Core, not in any of the five venues — for the whole time this spec
existed.

That is the worst shape a missing feature can take, and worse than an error would have
been. `:telemetry.attach/4` against a name nobody emits **succeeds**. So a consumer wired
a dashboard to `[:dp_exchange, :request, :stop]`, got no error, and saw an empty panel —
which reads as *a venue with no traffic*, not as *a spec nothing implements*. The failure
mode is a metric that stays plausible while only its meaning is wrong, which is the defect
this family names most often, sitting in the observability layer where it is least likely
to be questioned.

It was found the same way and in the same week as the identical hole in
`Core.Venue.subscribe/2`'s back-pressure paragraph — by reading the contract and grepping
for anything that honoured it. Two for two on the things Core declared and nobody built,
which is the real lesson: **a guarantee written in a doc and nowhere else is not a
guarantee, it is a plan.**

## Emitting through this module, not through `:telemetry.execute/3` directly

The functions below exist so no venue has to name an event itself. Five packages writing
`[:dp_exchange, :link, :up]` by hand is five chances to write `:link_up`, and the drift a
shared contract exists to prevent would be invisible: the wrong name emits successfully
and simply never reaches the handler. It also keeps `:telemetry` a dependency of Core
alone — a venue calling `:telemetry.execute/3` directly would be using a transitive
dependency it never declared.

## Emitting is safe for the caller

`:telemetry.execute/3` runs every attached handler **synchronously, in the calling
process**. A consumer's handler that raises would otherwise be a consumer's bug taking
down a venue's request path. `:telemetry` itself guards this — since 1.0 it wraps each
handler invocation and detaches one that raises, rather than propagating — so these calls
cannot be the reason a market-data call fails. That property is load-bearing and is why
they are not additionally wrapped here: a `try` around a call that already cannot raise
would only hide a real fault in this module.

## Event names

All names are nested lists under `[:dp_exchange, <category>, <event>]`.

### Request lifecycle

- `[:dp_exchange, :request, :start]` — about to fire an outbound
  request. Measurements: `%{system_time: integer}`. Metadata:
  `%{provider:, endpoint:, method:}`.
- `[:dp_exchange, :request, :stop]` — request returned (either
  success or error). Measurements: `%{duration: integer (native)}`.
  Metadata: `%{provider:, endpoint:, method:, status:, result:}`, where `status` is the
  HTTP status or `nil` when none was received and `result` is `:ok`, `:error` or
  `:rate_limited`. `Core.HttpClient` adds, where they apply: `retry_after_ms:` (on
  `:rate_limited`), and `reason: :response_too_large` with `max_body_bytes:` (on a body
  over the cap). The venue's response body is never carried.
- `[:dp_exchange, :request, :exception]` — request raised, exited or threw.
  Measurements: `%{duration: integer (native)}`. Metadata: same
  as `:stop` plus `%{kind:, reason:, stacktrace:}`, where `kind` is `:error`, `:exit` or
  `:throw`, `status` is `nil` and `result` is `:exception` (not one of `:stop`'s values).
  Every `:start` is followed by exactly one `:stop` or `:exception`. `reason` is bounded text, and
  `Core.HttpClient` reduces each stacktrace frame's argument list to its arity: a frame
  that failed to match records the request's arguments, headers and signature included.

### Rate limiting

- `[:dp_exchange, :rate_limit, :hit]` — caller was rate-limited, by this package's limiter
  or by the venue's own 429. Once per throttled request, whichever party decided.
  Measurements: `%{count: 1}`. Metadata: `%{provider:, retry_after_ms:}`, milliseconds
  (`provider` is the venue's identity, never a bucket chosen with `HttpClient`'s
  `:rate_limit_key`; a per-endpoint bucket reports `"<provider> <path>"`).
- `[:dp_exchange, :rate_limit, :acquire]` — caller acquired tokens.
  Measurements: `%{tokens: pos_integer, wait_ms: non_neg_integer}`.
  Metadata: `%{provider:, weight:}`.

### Link lifecycle

- `[:dp_exchange, :link, :up]` — a live route to the venue was established.
  Measurements: `%{count: 1}`. Metadata: `%{provider:}`. `Core.PollingFeed` emits it on
  its first delivery and again on each recovery from delivering-nothing.
- `[:dp_exchange, :link, :down]` — the route was lost.
  Measurements: `%{count: 1}`. Metadata: `%{provider:, reason:}`.
- `[:dp_exchange, :link, :event]` — a payload arrived over the route.
  Measurements: `%{count: 1, bytes: integer}` from `link_event/3`, or `%{count: 1}` with no
  `:bytes` from `link_event/2`, a route that cannot measure wire size. Metadata:
  `%{provider:, type:}`.
- `[:dp_exchange, :link, :reconnect_attempt]` — re-establishing after a drop.
  Measurements: `%{count: 1}`. Metadata: `%{provider:, attempt: integer, delay_ms: integer}`.

No `endpoint` metadata: a URL is transport, and a venue with no URL to report would
have to invent one.

# `request_metadata`

```elixir
@type request_metadata() :: %{
  :provider =&gt; atom() | String.t(),
  :endpoint =&gt; String.t(),
  :method =&gt; atom() | String.t(),
  optional(atom()) =&gt; term()
}
```

Metadata every request event carries: which venue, which endpoint, which verb.

# `endpoint`

```elixir
@spec endpoint(String.t()) :: String.t()
```

Strips a URL down to what is safe and useful as `:endpoint` metadata.

Query string removed, and that is a security decision rather than tidiness: telemetry
metadata reaches logs, aggregators and third-party exporters, and a query string is the
one part of a URL that can carry a token. No venue in this family signs in the query
today — every one uses headers — but `endpoint` is emitted on every request from every
venue present and future, and "none of them do that yet" is not a property a consumer's
log retention should depend on. Truncated at 200 characters so an unusually long path
cannot turn a metrics label into a memory problem.

`user:pass@` is removed too, and it fails toward not leaking: the authority is everything
after `://` up to the first `/` (or the end), and anything up to its last `@` is dropped.
A URL with no path and an `@` in its query, such as `https://host?email=a@b`, therefore
loses its host label (`https://b`). That is accepted over the alternative of leaking the
front of a password that contains a raw `?` or `#`.

# `event_prefixes`

```elixir
@spec event_prefixes() :: [[atom()]]
```

All telemetry event prefixes this module documents.

# `link_down`

```elixir
@spec link_down(atom() | String.t(), term()) :: :ok
```

Emits `[:dp_exchange, :link, :down]` — the route was lost.

`reason` is already-inspected text, never a raw term: telemetry metadata is read by
aggregators that group by value, and a raw reason carrying a pid or a socket ref makes
every occurrence a distinct series. It is also the shape `Core.Notice`'s own
`details.reason` uses, so the two channels agree about one event.

# `link_event`

```elixir
@spec link_event(atom() | String.t(), atom() | String.t()) :: :ok
```

Emits `[:dp_exchange, :link, :event]` for a route that cannot measure wire size.

A polling route has no frame: `Core.PollingFeed` receives a decoded HTTP body and hands
on a `Core.Types.*` struct, and there is no point at which a byte count of the payload
means what `link_event/3`'s does on a socket. So `:bytes` is **absent**, not zero.

Absent and zero are different claims and only one of them is true here — the same
distinction `link_reconnect_attempt/3` makes about `delay_ms`. A consumer summing
`:bytes` across a mixed fleet gets the throughput of the streaming venues, correctly,
instead of a total silently depressed by every polling venue reporting a confident zero.

# `link_event`

```elixir
@spec link_event(atom() | String.t(), atom() | String.t(), non_neg_integer()) :: :ok
```

Emits `[:dp_exchange, :link, :event]` — a payload arrived over the route.

The high-frequency one: once per frame, so it is on the hot path of every streaming
venue and deliberately does nothing but execute. `type` names the kind of payload in the
venue's own terms; `bytes` is the size on the wire, which is what makes this usable as a
throughput signal rather than only a count.

# `link_reconnect_attempt`

```elixir
@spec link_reconnect_attempt(atom() | String.t(), pos_integer(), non_neg_integer()) ::
  :ok
```

Emits `[:dp_exchange, :link, :reconnect_attempt]` — re-establishing after a drop.

Emitted per attempt, with the attempt number and the delay about to be waited, so a
consumer can see a backoff working rather than only that reconnects are happening. A
venue that reconnects immediately reports `delay_ms: 0` rather than omitting the field —
absent and zero mean different things, and only one of them is true here.

# `link_up`

```elixir
@spec link_up(atom() | String.t()) :: :ok
```

Emits `[:dp_exchange, :link, :up]` — a live route to the venue was established.

"Route", not "socket": what carries it is package-internal, and a venue that polls has a
link too. See "Why the category is `:link` and not `:ws`" above.

# `rate_limit_acquire`

```elixir
@spec rate_limit_acquire(atom() | String.t(), pos_integer(), non_neg_integer()) :: :ok
```

Emits `[:dp_exchange, :rate_limit, :acquire]` — the caller got its tokens.

`wait_ms` of zero is the normal, healthy case and is still emitted. A panel that only
sees the waits cannot tell a limiter that is never binding from one that is not running
at all, and `Core.HttpClient` fails closed when no limiter is reachable — so "no acquire
events" is a condition worth being able to see.

# `rate_limit_hit`

```elixir
@spec rate_limit_hit(atom() | String.t(), non_neg_integer()) :: :ok
```

Emits `[:dp_exchange, :rate_limit, :hit]` — the caller was told to wait.

`retry_after_ms` is what the limiter (or the venue's own `Retry-After`) said, in
milliseconds. Always milliseconds here even where a venue's header is in seconds: one
unit across the family is the whole reason these names are standardised, and a panel
summing a mixture of the two is wrong by a factor of a thousand without looking wrong.

# `request_exception`

```elixir
@spec request_exception(integer(), request_metadata(), keyword()) :: :ok
```

Emits `[:dp_exchange, :request, :exception]` — a request that raised rather than
returning.

Distinct from a `:stop` carrying an error result, and the distinction is the point: an
error result is the venue answering badly, an exception is this package failing to ask.
Folding them together makes a client-side bug indistinguishable from a venue outage on
every dashboard built from these events.

# `request_start`

```elixir
@spec request_start(request_metadata()) :: integer()
```

Emits `[:dp_exchange, :request, :start]` and returns the monotonic instant to pass to
`request_stop/3` or `request_exception/3`.

Two clocks on purpose, and it is not redundancy. The measurement is
`System.system_time/0`, because a consumer correlating a request against a venue's own
logs needs a wall-clock instant that means the same thing on both sides. The returned
value is `System.monotonic_time/0`, because the *duration* computed from it must not be
a function of whether NTP stepped the clock mid-request — the same distinction
`dp_exchange_webull`'s rejected-symbol TTL had to be corrected for, and the same one
`:telemetry.span/3` makes.

# `request_stop`

```elixir
@spec request_stop(integer(), request_metadata(), keyword()) :: :ok
```

Emits `[:dp_exchange, :request, :stop]` — for **any** outcome, success or error.

Recording only successes is the same mistake this family already made in its rate-limit
accounting, where a bucket that counted only 2xx under-reported real usage as "83/240"
against 395 calls actually sent. A latency panel built on successes alone shows a venue
getting faster exactly as it starts failing, because the slow calls are the ones dropping
out of the sample.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
