Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ All notable changes to `ccxt_ocx` are recorded here. The format follows

### Phase 5: Production Hardening

#### Task 21: PromEx plugin (`CcxtOcx.PromEx.Plugin`)
**Completed** | [D:3/B:7/U:7 → Eff:2.33] 🎯

Ship-with-the-library PromEx plugin that maps every `[:ccxt_ocx, ...]` event to Prometheus metrics with zero glue.

- New `CcxtOcx.PromEx.Plugin` — `event_metrics/1` covers runtime memory (live), REST (Phase 2-reserved), and WS tick (Phase 3-reserved). `polling_metrics/1` is opt-in via `pool:` / `poll_rate:` opts and drives `CcxtOcx.RuntimePool.memory/1` on a timer.
- Tag normalizers default missing metadata keys to `"none"`, stringify atoms, and inspect PIDs — Prometheus labels stay stable across emission sites (Runtime, RuntimePool, future macro-generated callers).
- `{:prom_ex, "~> 1.11", optional: true}` — pulled in for compile but not forced on consumers. `:bandit` extended to `:test` so `PromEx.Plug` compiles. `:telemetry_metrics` added to dialyzer `plt_add_apps`.
- "Observability — PromEx" section in README with consumer config snippet.
- Tests cover plugin shape, polling gating, custom poll rates, and live-emission tag normalization.

#### Task 14: Telemetry events
**Completed** | [D:3/B:7/U:7 → Eff:2.33] 🎯

Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,54 @@ Telemetry events are emitted under the `[:ccxt_ocx]` prefix:

- `[:ccxt_ocx, :runtime, :memory]` — QuickJS memory stats from any runtime
or pool worker (see `CcxtOcx.Runtime.memory/1` and `CcxtOcx.RuntimePool.memory/1`).
- `[:ccxt_ocx, :rest, :start | :stop | :exception]` — reserved for Phase 2
`defunified` (Task 7); metric definitions exist now so dashboards stay
stable when emission lights up.
- `[:ccxt_ocx, :ws, :tick]` — reserved for Phase 3 `defstreaming` (Task 11).

Full event contract and handler examples live in `CcxtOcx.Telemetry`.

### PromEx (Prometheus / Grafana)

`ccxt_ocx` ships a first-class [PromEx](https://hex.pm/packages/prom_ex)
plugin that maps every event above to Prometheus metrics with zero glue.
PromEx is an **optional dependency** — consumers add it to their own app:

```elixir
# mix.exs (consumer app)
{:prom_ex, "~> 1.11"}
```

Then reference the plugin in PromEx config:

```elixir
defmodule MyApp.PromEx do
use PromEx, otp_app: :my_app

@impl true
def plugins do
[
PromEx.Plugins.Application,
PromEx.Plugins.Beam,
# Optional :pool / :poll_rate opts enable periodic pool memory snapshots.
{CcxtOcx.PromEx.Plugin, pool: MyApp.CcxtPool, poll_rate: 10_000}
]
end
end
```

Provided metrics:

- `ccxt_ocx_runtime_memory_malloc_size_bytes` / `_used_size_bytes` / `_obj_count`
(last_value, tags: `[:server, :pool, :phase]`)
- `ccxt_ocx_rest_duration_milliseconds` (distribution, buckets:
10/50/100/250/500/1000/5000 ms)
- `ccxt_ocx_rest_total` (counter)
- `ccxt_ocx_rest_exceptions_total` (counter)
- `ccxt_ocx_ws_ticks_total` (counter)

See `CcxtOcx.PromEx.Plugin` for tag-stability details and polling config.

## Live Exploration with Tidewave

While building the macro layer, the fastest way to understand real behavior is to drive the system live:
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
| Task 16 | ⬜ | 🎁 **production** · ApiToolkit integration [D:4/B:6/U:6 → Eff:1.5] 🚀 |
| Task 16b | ⬜ | 🎁 **production** · Hoist rate-limit + nonce state into Elixir [D:7/B:9/U:8 → Eff:1.21] 📋 |
| Task 17 | ⬜ | 🎁 **production** · Sandboxed deployment shape [D:5/B:8/U:5 → Eff:1.3] 📋 |
| Task 21 | ✅ | 🎁 **production** · PromEx plugin (`CcxtOcx.PromEx.Plugin`) [D:3/B:7/U:7 → Eff:2.33] 🎯 |
<!-- TASKS:END -->

---
Expand Down
257 changes: 257 additions & 0 deletions lib/ccxt_ocx/prom_ex/plugin.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
if Code.ensure_loaded?(PromEx.Plugin) do
defmodule CcxtOcx.PromEx.Plugin do
@moduledoc """
Ship-with-the-library [PromEx](https://hex.pm/packages/prom_ex) plugin for
the `[:ccxt_ocx, ...]` telemetry event family.

Consumers add `{:prom_ex, "~> 1.11"}` to their app's deps, then reference
this module in their PromEx config:

defmodule MyApp.PromEx do
use PromEx, otp_app: :my_app

@impl true
def plugins do
[
PromEx.Plugins.Application,
PromEx.Plugins.Beam,
# Optional :pool / :poll_rate opts enable periodic pool memory snapshots.
{CcxtOcx.PromEx.Plugin, pool: MyApp.CcxtPool, poll_rate: 10_000}
]
end
end

PromEx is declared as an **optional** dependency of `:ccxt_ocx`. This
module is wrapped in `Code.ensure_loaded?(PromEx.Plugin)` so that
consumer projects without `:prom_ex` in their own deps can still
compile `:ccxt_ocx` — the module simply isn't defined when PromEx is
absent.

## Provided metrics

### Event-based (always on)

Backed by `[:ccxt_ocx, :runtime, :memory]` — emitted from
`CcxtOcx.Runtime.memory/1`, runtime init/terminate, and
`CcxtOcx.RuntimePool.memory/1`:

* `ccxt_ocx_runtime_memory_malloc_size_bytes` (last_value)
* `ccxt_ocx_runtime_memory_used_size_bytes` (last_value)
* `ccxt_ocx_runtime_memory_obj_count` (last_value)

Backed by `[:ccxt_ocx, :rest, :stop | :exception]` — reserved for
Phase 2 `defunified` (Task 7); the metric definitions exist now so
dashboards stay stable when emission lights up:

* `ccxt_ocx_rest_duration_milliseconds` (distribution)
* `ccxt_ocx_rest_total` (counter)
* `ccxt_ocx_rest_exceptions_total` (counter)

Backed by `[:ccxt_ocx, :ws, :tick]` — reserved for Phase 3
`defstreaming` (Task 11):

* `ccxt_ocx_ws_ticks_total` (counter)

### Polling (opt-in)

When the plugin is configured with `pool: <pool_name>`, a periodic
timer calls `CcxtOcx.RuntimePool.memory/1` every `:poll_rate`
milliseconds (default 5_000). Each call emits a fresh
`[:ccxt_ocx, :runtime, :memory]` event captured by the event metrics
above. No additional metric definitions — polling here is purely a
driver, not a separate metric stream.

## Tag stability

All tag values are normalized through `tag_values/1` callbacks so that
Prometheus labels stay consistent across emission sites. Missing
metadata keys become `"none"`, runtime PIDs are inspected (`#PID<…>`),
and exceptions are normalized to `:kind` (`:error | :exit | :throw`).
"""

use PromEx.Plugin

alias CcxtOcx.Telemetry, as: T

@impl true
def event_metrics(_opts) do
[
runtime_event_metrics(),
rest_event_metrics(),
ws_event_metrics()
]
end

@impl true
def polling_metrics(opts) do
case Keyword.fetch(opts, :pool) do
{:ok, pool} ->
poll_rate = Keyword.get(opts, :poll_rate, 5_000)

[
Polling.build(
:ccxt_ocx_runtime_pool_memory_polling_metrics,
poll_rate,
{CcxtOcx.RuntimePool, :memory, [pool]},
[]
)
]

:error ->
[]
end
end

## ------------------------------------------------------------------
## Event-group builders
## ------------------------------------------------------------------

@spec runtime_event_metrics() :: Event.t()
defp runtime_event_metrics do
memory_event = T.__runtime_memory__()
memory_tags = [:server, :pool, :phase]
memory_tag_values = &normalize_memory_metadata/1

Event.build(:ccxt_ocx_runtime_event_metrics, [
last_value(
[:ccxt_ocx, :runtime, :memory, :malloc_size, :bytes],
event_name: memory_event,
measurement: :malloc_size,
description: "QuickJS malloc size for a ccxt_ocx runtime.",
tags: memory_tags,
tag_values: memory_tag_values,
unit: :byte
),
last_value(
[:ccxt_ocx, :runtime, :memory, :used_size, :bytes],
event_name: memory_event,
measurement: :memory_used_size,
description: "QuickJS used memory for a ccxt_ocx runtime.",
tags: memory_tags,
tag_values: memory_tag_values,
unit: :byte
),
last_value(
[:ccxt_ocx, :runtime, :memory, :obj_count],
event_name: memory_event,
measurement: :obj_count,
description: "QuickJS live object count for a ccxt_ocx runtime.",
tags: memory_tags,
tag_values: memory_tag_values
)
])
end

@spec rest_event_metrics() :: Event.t()
defp rest_event_metrics do
stop_event = T.__rest_stop__()
exception_event = T.__rest_exception__()
rest_tags = [:exchange, :method]
rest_tag_values = &normalize_rest_metadata/1
exception_tags = [:exchange, :method, :kind]
exception_tag_values = &normalize_rest_exception_metadata/1

Event.build(:ccxt_ocx_rest_event_metrics, [
distribution(
[:ccxt_ocx, :rest, :duration, :milliseconds],
event_name: stop_event,
measurement: :duration,
description: "CCXT REST call duration.",
tags: rest_tags,
tag_values: rest_tag_values,
unit: {:native, :millisecond},
reporter_options: [buckets: [10, 50, 100, 250, 500, 1000, 5000]]
),
counter(
[:ccxt_ocx, :rest, :total],
event_name: stop_event,
description: "CCXT REST call count.",
tags: rest_tags,
tag_values: rest_tag_values
),
counter(
[:ccxt_ocx, :rest, :exceptions, :total],
event_name: exception_event,
description: "CCXT REST call exception count.",
tags: exception_tags,
tag_values: exception_tag_values
)
])
end

@spec ws_event_metrics() :: Event.t()
defp ws_event_metrics do
tick_event = T.__ws_tick__()

Event.build(:ccxt_ocx_ws_event_metrics, [
counter(
[:ccxt_ocx, :ws, :ticks, :total],
event_name: tick_event,
description: "CCXT WebSocket inbound message count.",
tags: [:exchange, :stream, :type],
tag_values: &normalize_ws_tick_metadata/1
)
])
end

## ------------------------------------------------------------------
## Tag-value normalizers
## ------------------------------------------------------------------

@spec normalize_memory_metadata(:telemetry.event_metadata()) :: %{
server: String.t(),
pool: String.t(),
phase: String.t()
}
defp normalize_memory_metadata(metadata) do
%{
server: stringify(Map.get(metadata, :server)),
pool: stringify(Map.get(metadata, :pool)),
phase: stringify(Map.get(metadata, :phase))
}
end

@spec normalize_rest_metadata(:telemetry.event_metadata()) :: %{
exchange: String.t(),
method: String.t()
}
defp normalize_rest_metadata(metadata) do
%{
exchange: stringify(Map.get(metadata, :exchange)),
method: stringify(Map.get(metadata, :method))
}
end

@spec normalize_rest_exception_metadata(:telemetry.event_metadata()) :: %{
exchange: String.t(),
method: String.t(),
kind: String.t()
}
defp normalize_rest_exception_metadata(metadata) do
%{
exchange: stringify(Map.get(metadata, :exchange)),
method: stringify(Map.get(metadata, :method)),
kind: stringify(Map.get(metadata, :kind))
}
end

@spec normalize_ws_tick_metadata(:telemetry.event_metadata()) :: %{
exchange: String.t(),
stream: String.t(),
type: String.t()
}
defp normalize_ws_tick_metadata(metadata) do
%{
exchange: stringify(Map.get(metadata, :exchange)),
stream: stringify(Map.get(metadata, :stream)),
type: stringify(Map.get(metadata, :type))
}
end

@spec stringify(term()) :: String.t()
defp stringify(nil), do: "none"
defp stringify(value) when is_binary(value), do: value
defp stringify(value) when is_atom(value), do: Atom.to_string(value)
defp stringify(value), do: inspect(value)
end
end
8 changes: 8 additions & 0 deletions lib/ccxt_ocx/telemetry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ defmodule CcxtOcx.Telemetry do
:telemetry.detach(handler_id)

Or use the higher-level wrappers from this module when emitting from inside CcxtOcx.

## PromEx

See `CcxtOcx.PromEx.Plugin` for a ship-with-the-library
[PromEx](https://hex.pm/packages/prom_ex) plugin that maps every event
in this module to Prometheus metrics with zero glue. Consumers add
`{:prom_ex, "~> 1.11"}` to their own deps and reference the plugin in
their PromEx config.
"""

@prefix [:ccxt_ocx]
Expand Down
18 changes: 15 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ defmodule CcxtOcx.MixProject do
aliases: aliases(),
dialyzer: [
plt_add_deps: :apps_direct,
plt_add_apps: [:mix],
# :telemetry_metrics is transitively provided by :prom_ex (optional dep)
# — surface it for the CcxtOcx.PromEx.Plugin metric DSL (last_value/2,
# distribution/2, counter/2).
plt_add_apps: [:mix, :telemetry_metrics],
plt_local_path: "priv/plts",
plt_core_path: "priv/plts",
ignore_warnings: ".dialyzer_ignore.exs"
Expand Down Expand Up @@ -47,6 +50,13 @@ defmodule CcxtOcx.MixProject do
# Observability (Task 14 — telemetry events + future memory monitor)
{:telemetry, "~> 1.3"},

# Optional PromEx plugin (Task 21 — CcxtOcx.PromEx.Plugin).
# Consumers add prom_ex to their own deps. The plugin module
# itself is wrapped in `Code.ensure_loaded?(PromEx.Plugin)` so
# downstream projects that don't depend on prom_ex still compile
# ccxt_ocx cleanly — the module simply isn't defined for them.
{:prom_ex, "~> 1.11", optional: true},
Comment on lines +53 to +58

# JSON
{:jason, "~> 1.4.5"},

Expand All @@ -65,9 +75,11 @@ defmodule CcxtOcx.MixProject do
{:ex_ast, "~> 0.12.0", only: [:dev, :test], runtime: false},
{:reach, "~> 2.3.4", only: [:dev, :test], runtime: false},

# Tidewave (non-Phoenix)
# Tidewave (non-Phoenix). Bandit is also kept in :test so PromEx's
# transitively-optional `:plug` dep is available when the optional
# `:prom_ex` compiles under :test (see `CcxtOcx.PromEx.Plugin`).
{:tidewave, "~> 0.5.6", only: :dev},
{:bandit, "~> 1.11.1", only: :dev}
{:bandit, "~> 1.11.1", only: [:dev, :test]}
]
end

Expand Down
Loading
Loading