diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 0000000..47b4bc9 --- /dev/null +++ b/.iex.exs @@ -0,0 +1,24 @@ +# IEx auto-attach for ccxt_ocx maintainer dogfooding. +# +# Guarded so it only fires in an active Mix :dev shell โ€” `mix test`, plain +# `iex`, and any prod release path stay untouched. Also guarded on +# `Code.ensure_loaded?/1` so a stripped release (where CcxtOcx.DevTelemetry +# might be filtered out) doesn't blow up the shell. + +mix_dev? = + Code.ensure_loaded?(Mix) and function_exported?(Mix, :env, 0) and + try do + Mix.env() == :dev + rescue + ArgumentError -> false + end + +if mix_dev? and Code.ensure_loaded?(CcxtOcx.DevTelemetry) do + CcxtOcx.DevTelemetry.watch() + + IO.puts(""" + ๐Ÿ“ก CcxtOcx.DevTelemetry attached. + Call `CcxtOcx.DevTelemetry.summary/0` anytime to see counts + last values. + `reset/0` clears, `detach/0` stops counting. See docs/tidewave_examples.md. + """) +end diff --git a/ROADMAP.md b/ROADMAP.md index 6c245fd..cca14c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,6 +113,7 @@ | Task 18 | โฌœ | ๐ŸŽ **dx** ยท ex_doc + llms.txt [D:2/B:5/U:5 โ†’ Eff:2.5] ๐ŸŽฏ | | Task 19 | โฌœ | ๐ŸŽ **dx** ยท Descripex annotations on the public API [D:3/B:5/U:4 โ†’ Eff:1.5] ๐Ÿš€ | | Task 20 | ๐Ÿ”„ | ๐ŸŽ **dx** ยท Tidewave examples [D:2/B:4/U:5 โ†’ Eff:2.25] ๐ŸŽฏ | +| Task 22 | โœ… | ๐ŸŽ **dx** ยท Dev telemetry dogfooding loop (CcxtOcx.DevTelemetry) [D:2/B:5/U:8 โ†’ Eff:3.25] ๐ŸŽฏ | --- diff --git a/docs/tidewave_examples.md b/docs/tidewave_examples.md index abeb044..cd30e7a 100644 --- a/docs/tidewave_examples.md +++ b/docs/tidewave_examples.md @@ -149,6 +149,62 @@ CcxtOcx.Runtime.memory_usage(:rt) # same map, no telemetry side-effect end, %{}) ``` +## Dev Telemetry Loop (Dogfooding) + +`CcxtOcx.DevTelemetry` is the maintainer-side companion to `CcxtOcx.Telemetry`: one +call attaches a handler to every `[:ccxt_ocx, ...]` event family, pretty-prints each +emission to stdout, and keeps running counts + last-values in an `Agent` you can +`summary/0` at any time. Use it as the validation loop for new emission sites +(Task 7 `defunified`, Task 11 `defstreaming`, Task 15 memory monitor) before +downstream consumers (`CcxtOcx.PromEx.Plugin`, Grafana dashboards) bake in +assumptions about the contract. + +```elixir +# Attach once per session โ€” idempotent (re-attach detaches the prior handler +# and resets counts). +CcxtOcx.DevTelemetry.watch() + +# Drive load. Every emission lands as a one-line print + an Agent bump. +{:ok, _} = CcxtOcx.Runtime.start_link(name: :rt) +CcxtOcx.Runtime.memory(:rt) +# => [memory] malloc=12.3M used=8.1M objs=14523 server=#PID<0.345.0> phase=manual + +# Snapshot when you want to assert the shape. +CcxtOcx.DevTelemetry.summary() +# => %{ +# runtime_memory: %{count: 2, last_measurements: %{...}, last_metadata: %{...}}, +# rest_start: %{count: 0, last_measurements: nil, last_metadata: nil}, +# rest_stop: %{count: 0, last_measurements: nil, last_metadata: nil}, +# rest_exception: %{count: 0, last_measurements: nil, last_metadata: nil}, +# ws_tick: %{count: 0, last_measurements: nil, last_metadata: nil} +# } + +# Clear counters without re-attaching. +CcxtOcx.DevTelemetry.reset() + +# Detach when you're done. +CcxtOcx.DevTelemetry.detach() +``` + +`watch/1` takes a few options for ad-hoc filtering: + +```elixir +# Silence pretty-print, keep counters only (handy when driving high-frequency +# WS load and the prints would scroll past too fast to read). +CcxtOcx.DevTelemetry.watch(print: false) + +# Restrict to a subset of event families. +CcxtOcx.DevTelemetry.watch(filter: [:runtime_memory, :ws_tick]) + +# Send pretty-print to a different IO device (e.g. a log file). +{:ok, log} = File.open("/tmp/ccxt_ocx.log", [:write]) +CcxtOcx.DevTelemetry.watch(io_device: log) +``` + +In dev, `iex -S mix` (and therefore `iex -S mix tidewave`) auto-attaches +`DevTelemetry` via the project's `.iex.exs`, so you can drop straight into +`summary/0` without a manual `watch/0` call. + ## Using Other Tidewave Tools Together While you have a runtime running, combine it with Tidewave's other MCP tools: diff --git a/lib/ccxt_ocx/dev_telemetry.ex b/lib/ccxt_ocx/dev_telemetry.ex new file mode 100644 index 0000000..bcff92d --- /dev/null +++ b/lib/ccxt_ocx/dev_telemetry.ex @@ -0,0 +1,257 @@ +defmodule CcxtOcx.DevTelemetry do + @moduledoc """ + Maintainer-side dogfooding helpers for ccxt_ocx telemetry. + + Attach to every `[:ccxt_ocx, ...]` event family in one call, pretty-print + each emission to a configurable IO device, keep running counts plus the + last-seen measurements/metadata in an `Agent`, dump the summary on demand. + + For Tidewave / IEx sessions only โ€” this module owns a named process and + prints to stdout by default, so it has no business in `start/0` / + `Application.start/2`. There is no supervisor entry; `watch/1` starts the + Agent lazily. + + ## Usage + + iex> CcxtOcx.DevTelemetry.watch() + :ok + iex> CcxtOcx.Runtime.memory(:rt) + [memory] malloc=12.3M used=8.1M objs=14523 server=#PID<0.345.0> phase=manual + iex> CcxtOcx.DevTelemetry.summary() + %{ + runtime_memory: %{count: 1, last_measurements: %{...}, last_metadata: %{...}}, + rest_start: %{count: 0, last_measurements: nil, last_metadata: nil}, + rest_stop: %{count: 0, last_measurements: nil, last_metadata: nil}, + rest_exception: %{count: 0, last_measurements: nil, last_metadata: nil}, + ws_tick: %{count: 0, last_measurements: nil, last_metadata: nil} + } + + See `docs/tidewave_examples.md` ยง "Dev Telemetry Loop" for the + observe-while-driving cycle this module is designed for. + + ## Options for `watch/1` + + * `:print` โ€” print each emission to `:io_device`. Default: `true`. + * `:filter` โ€” restrict to a subset of event keys (see `t:event_key/0`). + Default: all events. + * `:io_device` โ€” where to write pretty-print output. Default: `:stdio`. + + Idempotent โ€” re-calling `watch/1` detaches the previous handler and + resets state before re-attaching with the new options. + """ + + @handler_id "ccxt-ocx-dev-telemetry" + + @events %{ + runtime_memory: [:ccxt_ocx, :runtime, :memory], + rest_start: [:ccxt_ocx, :rest, :start], + rest_stop: [:ccxt_ocx, :rest, :stop], + rest_exception: [:ccxt_ocx, :rest, :exception], + ws_tick: [:ccxt_ocx, :ws, :tick] + } + + @watch_schema NimbleOptions.new!( + print: [ + type: :boolean, + default: true + ], + filter: [ + type: {:list, {:in, Map.keys(@events)}}, + default: Map.keys(@events) + ], + io_device: [ + type: :any, + default: :stdio + ] + ) + + @type event_key :: + :runtime_memory | :rest_start | :rest_stop | :rest_exception | :ws_tick + + @type entry :: %{ + count: non_neg_integer(), + last_measurements: map() | nil, + last_metadata: map() | nil + } + + @type state :: %{event_key() => entry()} + + @type opts :: [ + print: boolean(), + filter: [event_key()], + io_device: IO.device() + ] + + @doc """ + Attach a single handler to every `[:ccxt_ocx, ...]` event family. + + Idempotent โ€” re-calling detaches the previous handler and resets the + in-memory state before re-attaching. + """ + @spec watch(opts()) :: :ok + def watch(opts \\ []) do + opts = NimbleOptions.validate!(opts, @watch_schema) + print? = Keyword.fetch!(opts, :print) + io_device = Keyword.fetch!(opts, :io_device) + filter = Keyword.fetch!(opts, :filter) + events = Enum.map(filter, &Map.fetch!(@events, &1)) + + :ok = detach() + :ok = ensure_agent() + :ok = Agent.update(__MODULE__, fn _ -> initial_state() end) + + :ok = + :telemetry.attach_many( + @handler_id, + events, + &__MODULE__.handle_event/4, + %{print?: print?, io_device: io_device} + ) + + :ok + end + + @doc """ + Detach the dev-telemetry handler. Idempotent. + + Leaves the Agent (and its accumulated counts) alone โ€” call `reset/0` to + clear, or `summary/0` to inspect post-detach. + """ + @spec detach() :: :ok + def detach do + _ = :telemetry.detach(@handler_id) + :ok + end + + @doc """ + Current in-memory state. + + Returns the initial all-zeros state if `watch/1` was never called. + """ + @spec summary() :: state() + def summary do + case Process.whereis(__MODULE__) do + nil -> initial_state() + _pid -> Agent.get(__MODULE__, & &1) + end + end + + @doc """ + Reset all counts and last-values to the initial state. + + No-op if `watch/1` was never called. + """ + @spec reset() :: :ok + def reset do + case Process.whereis(__MODULE__) do + nil -> :ok + _pid -> Agent.update(__MODULE__, fn _ -> initial_state() end) + end + end + + @doc false + @spec handle_event([atom()], map(), map(), map()) :: :ok + def handle_event(event, measurements, metadata, config) do + key = event_to_key(event) + + if Process.whereis(__MODULE__) do + Agent.update(__MODULE__, fn state -> + entry = Map.fetch!(state, key) + + new_entry = %{ + entry + | count: entry.count + 1, + last_measurements: measurements, + last_metadata: metadata + } + + Map.put(state, key, new_entry) + end) + end + + if Map.get(config, :print?, false) do + io_device = Map.get(config, :io_device, :stdio) + IO.puts(io_device, format(key, measurements, metadata)) + end + + :ok + end + + # ------------------------------------------------------------------ + # private + # ------------------------------------------------------------------ + + @spec ensure_agent() :: :ok + defp ensure_agent do + case Agent.start(fn -> initial_state() end, name: __MODULE__) do + {:ok, _pid} -> :ok + {:error, {:already_started, _pid}} -> :ok + end + end + + @spec initial_state() :: state() + defp initial_state do + @events + |> Map.keys() + |> Map.new(fn key -> {key, %{count: 0, last_measurements: nil, last_metadata: nil}} end) + end + + @spec event_to_key([atom()]) :: event_key() + defp event_to_key([:ccxt_ocx, :runtime, :memory]), do: :runtime_memory + defp event_to_key([:ccxt_ocx, :rest, :start]), do: :rest_start + defp event_to_key([:ccxt_ocx, :rest, :stop]), do: :rest_stop + defp event_to_key([:ccxt_ocx, :rest, :exception]), do: :rest_exception + defp event_to_key([:ccxt_ocx, :ws, :tick]), do: :ws_tick + + @spec format(event_key(), map(), map()) :: String.t() + defp format(:runtime_memory, meas, meta) do + malloc = format_bytes(Map.get(meas, :malloc_size)) + used = format_bytes(Map.get(meas, :memory_used_size)) + objs = Map.get(meas, :obj_count, "?") + phase = Map.get(meta, :phase, :manual) + + target = + cond do + Map.has_key?(meta, :server) -> "server=#{inspect(meta.server)}" + Map.has_key?(meta, :pool) -> "pool=#{inspect(meta.pool)}" + true -> "target=?" + end + + "[memory] malloc=#{malloc} used=#{used} objs=#{objs} #{target} phase=#{phase}" + end + + defp format(:rest_start, _meas, meta) do + "[rest:start] exchange=#{Map.get(meta, :exchange, "?")} method=#{Map.get(meta, :method, "?")}" + end + + defp format(:rest_stop, meas, meta) do + duration = format_duration(Map.get(meas, :duration)) + + "[rest:stop] exchange=#{Map.get(meta, :exchange, "?")} method=#{Map.get(meta, :method, "?")} duration=#{duration}" + end + + defp format(:rest_exception, _meas, meta) do + "[rest:exception] exchange=#{Map.get(meta, :exchange, "?")} method=#{Map.get(meta, :method, "?")} kind=#{Map.get(meta, :kind, "?")}" + end + + defp format(:ws_tick, _meas, meta) do + "[ws:tick] exchange=#{Map.get(meta, :exchange, "?")} stream=#{Map.get(meta, :stream, "?")} type=#{Map.get(meta, :type, "?")}" + end + + @spec format_bytes(integer() | nil) :: String.t() + defp format_bytes(nil), do: "?" + + defp format_bytes(n) when is_integer(n) and n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M" + + defp format_bytes(n) when is_integer(n) and n >= 1_000, do: "#{Float.round(n / 1_000, 1)}K" + + defp format_bytes(n) when is_integer(n), do: "#{n}B" + + @spec format_duration(integer() | nil) :: String.t() + defp format_duration(nil), do: "?" + + defp format_duration(n) when is_integer(n) do + us = System.convert_time_unit(n, :native, :microsecond) + "#{Float.round(us / 1000, 2)}ms" + end +end diff --git a/mix.exs b/mix.exs index 88d4826..3a66991 100644 --- a/mix.exs +++ b/mix.exs @@ -49,6 +49,7 @@ defmodule CcxtOcx.MixProject do # Observability (Task 14 โ€” telemetry events + future memory monitor) {:telemetry, "~> 1.3"}, + {:nimble_options, "~> 1.1"}, # Optional PromEx plugin (Task 21 โ€” CcxtOcx.PromEx.Plugin). # Consumers add prom_ex to their own deps. The plugin module diff --git a/roadmap/data.json b/roadmap/data.json index 13f21aa..c4dd3eb 100644 --- a/roadmap/data.json +++ b/roadmap/data.json @@ -938,6 +938,46 @@ "body": "Document the operational tax: every CCXT release ships adapter fixes (new fields, normalized formats, bug fixes for venue API changes). Once on native, those don't propagate. Either pin a CCXT version and accept drift, or subscribe to CCXT's git log and port relevant fixes per release for migrated exchanges. The honest framing is \"native exchanges are now your responsibility to maintain\"; budget engineering time accordingly.\n", "scored_at": "2026-05-16", "cross_repo": [] + }, + { + "id": "22", + "phase": 6, + "bundle": "dx", + "status": "done", + "title": "Dev telemetry dogfooding loop (CcxtOcx.DevTelemetry)", + "scores": { + "d": 2, + "b": 5, + "u": 8 + }, + "eff": 3.25, + "markers": [], + "depends_on": [ + "14" + ], + "acceptance_criteria": [ + "lib/ccxt_ocx/dev_telemetry.ex exports watch/1, summary/0, reset/0, detach/0", + "watch/1 is idempotent โ€” re-call resets state and only one handler stays attached", + "watch/1 supports :print, :filter, :io_device options", + "summary/0 returns initial all-zeros state when watch/1 was never called (no Agent process)", + "reset/0 is a no-op when Agent was never started", + "detach/0 is idempotent", + "Every event family routes to its own counter bucket (runtime_memory, rest_start/stop/exception, ws_tick)", + ".iex.exs auto-attaches in dev with a one-line banner; guarded on Mix.env() == :dev AND Code.ensure_loaded?", + "docs/tidewave_examples.md has a 'Dev Telemetry Loop' section showing watch โ†’ drive โ†’ summary โ†’ reset โ†’ detach", + "Full harness green: format, compile --warnings-as-errors, test.json, credo --strict, dialyzer.json, doctor" + ], + "out_of_scope": [ + "Persisting DevTelemetry state across IEx sessions", + "Production-mode telemetry (logging handlers, structured-log shipping)", + "Supervisor entry โ€” watch/1 starts the Agent lazily; dev tool only" + ], + "body": "Maintainer-side dogfooding companion to CcxtOcx.Telemetry. One `watch/1` call\nattaches to every [:ccxt_ocx, ...] event family, pretty-prints each emission\nto a configurable IO device, and keeps running counts plus last-seen\nmeasurements/metadata in an Agent. `summary/0` dumps state on demand;\n`reset/0` clears; `detach/0` stops counting.\n\nUsed as the validation loop for every new emission site (Task 7 defunified,\nTask 11 defstreaming, Task 15 memory monitor) before downstream consumers\n(PromEx plugin, Grafana) bake in assumptions about the contract. Auto-attached\nin dev via .iex.exs banner.\n", + "created_at": "2026-05-17", + "done_at": "2026-05-17", + "scored_at": "2026-05-17", + "implemented": "Shipped lib/ccxt_ocx/dev_telemetry.ex (Agent-backed dogfooding module: watch/1, summary/0, reset/0, detach/0; idempotent attach; :print/:filter/:io_device options; one-line pretty-print per event family). 14 tests covering attach/idempotency/filter/io_device/per-event routing/accumulation/last-wins/reset/detach. Added .iex.exs auto-attach with banner (guarded on Mix.env() == :dev AND Code.ensure_loaded?). docs/tidewave_examples.md: new 'Dev Telemetry Loop' section between Memory monitoring and Tidewave tools.", + "cross_repo": [] } ] -} \ No newline at end of file +} diff --git a/roadmap/tasks.toml b/roadmap/tasks.toml index c24a9fa..4067325 100644 --- a/roadmap/tasks.toml +++ b/roadmap/tasks.toml @@ -768,3 +768,30 @@ scored_at = "2026-05-16" body = """ Document the operational tax: every CCXT release ships adapter fixes (new fields, normalized formats, bug fixes for venue API changes). Once on native, those don't propagate. Either pin a CCXT version and accept drift, or subscribe to CCXT's git log and port relevant fixes per release for migrated exchanges. The honest framing is "native exchanges are now your responsibility to maintain"; budget engineering time accordingly. """ + +[[task]] +id = "22" +phase = 6 +bundle = "dx" +status = "done" +title = "Dev telemetry dogfooding loop (CcxtOcx.DevTelemetry)" +scores = { d = 2, b = 5, u = 8 } +depends_on = ["14"] +acceptance_criteria = ["lib/ccxt_ocx/dev_telemetry.ex exports watch/1, summary/0, reset/0, detach/0", "watch/1 is idempotent โ€” re-call resets state and only one handler stays attached", "watch/1 supports :print, :filter, :io_device options", "summary/0 returns initial all-zeros state when watch/1 was never called (no Agent process)", "reset/0 is a no-op when Agent was never started", "detach/0 is idempotent", "Every event family routes to its own counter bucket (runtime_memory, rest_start/stop/exception, ws_tick)", ".iex.exs auto-attaches in dev with a one-line banner; guarded on Mix.env() == :dev AND Code.ensure_loaded?", "docs/tidewave_examples.md has a 'Dev Telemetry Loop' section showing watch โ†’ drive โ†’ summary โ†’ reset โ†’ detach", "Full harness green: format, compile --warnings-as-errors, test.json, credo --strict, dialyzer.json, doctor"] +out_of_scope = ["Persisting DevTelemetry state across IEx sessions", "Production-mode telemetry (logging handlers, structured-log shipping)", "Supervisor entry โ€” watch/1 starts the Agent lazily; dev tool only"] +body = """ +Maintainer-side dogfooding companion to CcxtOcx.Telemetry. One `watch/1` call +attaches to every [:ccxt_ocx, ...] event family, pretty-prints each emission +to a configurable IO device, and keeps running counts plus last-seen +measurements/metadata in an Agent. `summary/0` dumps state on demand; +`reset/0` clears; `detach/0` stops counting. + +Used as the validation loop for every new emission site (Task 7 defunified, +Task 11 defstreaming, Task 15 memory monitor) before downstream consumers +(PromEx plugin, Grafana) bake in assumptions about the contract. Auto-attached +in dev via .iex.exs banner. +""" +implemented = "Shipped lib/ccxt_ocx/dev_telemetry.ex (Agent-backed dogfooding module: watch/1, summary/0, reset/0, detach/0; idempotent attach; :print/:filter/:io_device options; one-line pretty-print per event family). 14 tests covering attach/idempotency/filter/io_device/per-event routing/accumulation/last-wins/reset/detach. Added .iex.exs auto-attach with banner (guarded on Mix.env() == :dev AND Code.ensure_loaded?). docs/tidewave_examples.md: new 'Dev Telemetry Loop' section between Memory monitoring and Tidewave tools." +created_at = "2026-05-17" +scored_at = "2026-05-17" +done_at = "2026-05-17" diff --git a/test/ccxt_ocx/bundle_surface_test.exs b/test/ccxt_ocx/bundle_surface_test.exs index d9edf33..8dc4169 100644 --- a/test/ccxt_ocx/bundle_surface_test.exs +++ b/test/ccxt_ocx/bundle_surface_test.exs @@ -24,6 +24,31 @@ defmodule CcxtOcx.BundleSurfaceTest do end describe "OXC extraction" do + test "extract_unified_methods filters a small local declaration fixture" do + path = + temp_file!("exchange_fixture.d.ts", """ + export default class Exchange { + fetchTicker(): Promise; + createOrder(): Promise; + withdraw(): Promise; + parseTrade(): object; + request(): Promise; + fetch2(): Promise; + loadMarketsHelper(): Promise; + } + """) + + assert Compile.extract_unified_methods(path) == ["createOrder", "fetchTicker", "withdraw"] + end + + test "extract_unified_methods raises when the declaration file is missing" do + missing_path = Path.join(System.tmp_dir!(), "ccxt_ocx_missing_exchange.d.ts") + + assert_raise RuntimeError, ~r/CCXT declaration file not found/, fn -> + Compile.extract_unified_methods(missing_path) + end + end + @tag :integration test "extract_unified_methods finds the smoke surface from Exchange.d.ts" do path = Compile.exchange_dts_path() @@ -131,6 +156,25 @@ defmodule CcxtOcx.BundleSurfaceTest do end describe "BundleSurface.build_snapshot/1" do + test "probe_has_tables raises when configured bundle path is missing" do + old_path = Application.get_env(:ccxt_ocx, :bundle_path) + missing_path = Path.join(System.tmp_dir!(), "ccxt_ocx_missing_bundle.js") + + Application.put_env(:ccxt_ocx, :bundle_path, missing_path) + + on_exit(fn -> + if old_path do + Application.put_env(:ccxt_ocx, :bundle_path, old_path) + else + Application.delete_env(:ccxt_ocx, :bundle_path) + end + end) + + assert_raise RuntimeError, ~r/CCXT bundle not found/, fn -> + Compile.probe_has_tables(["binance"]) + end + end + @tag :integration test "returns a snapshot with methods + sampled has for a small sample" do snap = BundleSurface.build_snapshot(["binance"]) @@ -254,4 +298,13 @@ defmodule CcxtOcx.BundleSurfaceTest do assert term == snapshot end end + + defp temp_file!(name, content) do + dir = Path.join(System.tmp_dir!(), "ccxt_ocx_bundle_surface_test_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + path = Path.join(dir, name) + File.write!(path, content) + on_exit(fn -> File.rm_rf(dir) end) + path + end end diff --git a/test/ccxt_ocx/dev_telemetry_test.exs b/test/ccxt_ocx/dev_telemetry_test.exs new file mode 100644 index 0000000..c489e63 --- /dev/null +++ b/test/ccxt_ocx/dev_telemetry_test.exs @@ -0,0 +1,246 @@ +defmodule CcxtOcx.DevTelemetryTest do + # async: false โ€” DevTelemetry registers a named Agent and a global telemetry + # handler, both of which would race other tests in this module if parallel. + use ExUnit.Case, async: false + + alias CcxtOcx.DevTelemetry + alias CcxtOcx.Telemetry + + setup do + on_exit(fn -> + DevTelemetry.detach() + stop_agent_if_running() + end) + + :ok + end + + describe "watch/1" do + test "attaches a handler and starts fresh state" do + assert :ok = DevTelemetry.watch(print: false) + + summary = DevTelemetry.summary() + assert summary.runtime_memory == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_start.count == 0 + assert summary.rest_stop.count == 0 + assert summary.rest_exception.count == 0 + assert summary.ws_tick.count == 0 + end + + test "is idempotent โ€” re-calling resets state and re-attaches without leaking handlers" do + :ok = DevTelemetry.watch(print: false) + + Telemetry.execute([:runtime, :memory], %{malloc_size: 100}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + + :ok = DevTelemetry.watch(print: false) + + # Re-attach clears counts. + assert DevTelemetry.summary().runtime_memory.count == 0 + + # And there's only one handler โ€” emit once, count once (not twice). + Telemetry.execute([:runtime, :memory], %{malloc_size: 100}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + end + + test ":filter restricts attached events" do + :ok = DevTelemetry.watch(print: false, filter: [:runtime_memory]) + + Telemetry.execute([:runtime, :memory], %{malloc_size: 100}, %{server: self()}) + Telemetry.execute([:ws, :tick], %{count: 1}, %{exchange: "binance", stream: "trade", type: "update"}) + + summary = DevTelemetry.summary() + assert summary.runtime_memory.count == 1 + # filtered events stay at zero + assert summary.ws_tick.count == 0 + end + + test "invalid options do not detach an existing handler or reset state" do + :ok = DevTelemetry.watch(print: false, filter: [:runtime_memory]) + Telemetry.execute([:runtime, :memory], %{malloc_size: 100}, %{server: self()}) + + assert_raise NimbleOptions.ValidationError, fn -> + DevTelemetry.watch(print: false, filter: [:unknown]) + end + + Telemetry.execute([:runtime, :memory], %{malloc_size: 200}, %{server: self()}) + + assert DevTelemetry.summary().runtime_memory.count == 2 + end + + test ":io_device routes pretty-print output (captures the format string)" do + {:ok, capture} = StringIO.open("") + :ok = DevTelemetry.watch(print: true, io_device: capture) + + Telemetry.execute( + [:runtime, :memory], + %{malloc_size: 12_300_000, memory_used_size: 8_100_000, obj_count: 14_523}, + %{ + server: self() + } + ) + + {_input, output} = StringIO.contents(capture) + assert output =~ "[memory]" + assert output =~ "malloc=12.3M" + assert output =~ "used=8.1M" + assert output =~ "objs=14523" + assert output =~ "phase=manual" + end + + test "print: false silences output but still updates counters" do + {:ok, capture} = StringIO.open("") + :ok = DevTelemetry.watch(print: false, io_device: capture) + + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + + {_input, output} = StringIO.contents(capture) + assert output == "" + assert DevTelemetry.summary().runtime_memory.count == 1 + end + + test "prints every event family format" do + {:ok, capture} = StringIO.open("") + :ok = DevTelemetry.watch(print: true, io_device: capture) + + Telemetry.execute([:runtime, :memory], %{malloc_size: 999, memory_used_size: 1_250, obj_count: 1}, %{pool: :pool}) + Telemetry.execute([:runtime, :memory], %{}, %{}) + Telemetry.execute([:rest, :start], %{}, %{exchange: "binance", method: "ticker"}) + Telemetry.execute([:rest, :stop], %{duration: 1_000_000}, %{exchange: "binance", method: "ticker"}) + Telemetry.execute([:rest, :stop], %{}, %{}) + Telemetry.execute([:rest, :exception], %{}, %{exchange: "binance", method: "ticker", kind: :error}) + Telemetry.execute([:ws, :tick], %{}, %{exchange: "binance", stream: "trade", type: "update"}) + + {_input, output} = StringIO.contents(capture) + assert output =~ "malloc=999B" + assert output =~ "used=1.3K" + assert output =~ "pool=:pool" + assert output =~ "target=?" + assert output =~ "[rest:start] exchange=binance method=ticker" + assert output =~ "[rest:stop] exchange=binance method=ticker duration=1.0ms" + assert output =~ "[rest:stop] exchange=? method=? duration=?" + assert output =~ "[rest:exception] exchange=binance method=ticker kind=error" + assert output =~ "[ws:tick] exchange=binance stream=trade type=update" + end + end + + describe ".iex.exs" do + test "does not crash outside a Mix shell" do + elixir = System.find_executable("elixir") + assert is_binary(elixir) + + {output, status} = + System.cmd(elixir, ["-e", "Code.eval_file(\".iex.exs\")"], stderr_to_stdout: true) + + assert status == 0, output + end + end + + describe "emission landing" do + setup do + :ok = DevTelemetry.watch(print: false) + :ok + end + + test "[:ccxt_ocx, :runtime, :memory] increments runtime_memory and stores last measurements/meta" do + meas = %{malloc_size: 100, memory_used_size: 80, obj_count: 5} + meta = %{server: self(), phase: :init} + + Telemetry.execute([:runtime, :memory], meas, meta) + + entry = DevTelemetry.summary().runtime_memory + assert entry.count == 1 + assert entry.last_measurements == meas + assert entry.last_metadata == meta + end + + test "[:ccxt_ocx, :rest, :start|:stop|:exception] each route to their own bucket" do + Telemetry.execute([:rest, :start], %{system_time: 1}, %{exchange: "binance", method: "ticker"}) + Telemetry.execute([:rest, :stop], %{duration: 1_000_000}, %{exchange: "binance", method: "ticker"}) + + Telemetry.execute([:rest, :exception], %{duration: 1_000_000}, %{ + exchange: "binance", + method: "ticker", + kind: :error + }) + + summary = DevTelemetry.summary() + assert summary.rest_start.count == 1 + assert summary.rest_stop.count == 1 + assert summary.rest_exception.count == 1 + end + + test "[:ccxt_ocx, :ws, :tick] increments ws_tick" do + Telemetry.execute([:ws, :tick], %{count: 1}, %{exchange: "binance", stream: "trade", type: "update"}) + Telemetry.execute([:ws, :tick], %{count: 1}, %{exchange: "binance", stream: "trade", type: "update"}) + + assert DevTelemetry.summary().ws_tick.count == 2 + end + + test "multiple emissions accumulate the count and overwrite last_*" do + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + Telemetry.execute([:runtime, :memory], %{malloc_size: 2}, %{server: self()}) + Telemetry.execute([:runtime, :memory], %{malloc_size: 3}, %{server: self()}) + + entry = DevTelemetry.summary().runtime_memory + assert entry.count == 3 + # last-wins + assert entry.last_measurements == %{malloc_size: 3} + end + end + + describe "reset/0" do + test "clears counts and last-values when Agent is running" do + :ok = DevTelemetry.watch(print: false) + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + + :ok = DevTelemetry.reset() + assert DevTelemetry.summary().runtime_memory == %{count: 0, last_measurements: nil, last_metadata: nil} + end + + test "is a no-op when watch/1 was never called" do + assert :ok = DevTelemetry.reset() + end + end + + describe "summary/0" do + test "returns the initial all-zeros state when watch/1 was never called" do + summary = DevTelemetry.summary() + assert summary.runtime_memory == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_start == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_stop == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.rest_exception == %{count: 0, last_measurements: nil, last_metadata: nil} + assert summary.ws_tick == %{count: 0, last_measurements: nil, last_metadata: nil} + end + end + + describe "detach/0" do + test "stops counting further emissions but preserves prior state" do + :ok = DevTelemetry.watch(print: false) + Telemetry.execute([:runtime, :memory], %{malloc_size: 1}, %{server: self()}) + assert DevTelemetry.summary().runtime_memory.count == 1 + + :ok = DevTelemetry.detach() + + Telemetry.execute([:runtime, :memory], %{malloc_size: 2}, %{server: self()}) + + # Count is unchanged after detach โ€” handler is gone. + assert DevTelemetry.summary().runtime_memory.count == 1 + end + + test "is idempotent" do + assert :ok = DevTelemetry.detach() + assert :ok = DevTelemetry.detach() + end + end + + # ------------------------------------------------------------------ + + defp stop_agent_if_running do + case Process.whereis(DevTelemetry) do + nil -> :ok + pid -> Agent.stop(pid) + end + end +end