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
60 changes: 60 additions & 0 deletions docs/manual-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,42 @@ Use manual tracking when your framework is not covered by a WildEdge integration
- You want to attach input/output metadata (token counts, image dimensions, confidence scores, etc.)
- You want to record user feedback tied to a specific inference

## The default client

Every process has at most one default client, and the module-level API
delegates to it. You rarely need to hold a client instance yourself:

```python
import wildedge

with wildedge.trace(run_id="run-123"):
with wildedge.span(kind="agent_step", name="plan", step_index=1):
...
```

The default client comes from wherever initialization happened first:

- `wildedge run` installs one before your code loads. A later
`wildedge.init()` without `dsn` reuses it, so the same code works with or
without the CLI wrapper.
- `wildedge.init(...)` creates one (or reuses the existing default) and
registers it.
- Any module-level call (`wildedge.span`, `wildedge.register_model`, ...)
creates one lazily from the environment: `WILDEDGE_DSN` for the endpoint
(without it the client is a no-op), plus `WILDEDGE_INTEGRATIONS` and
`WILDEDGE_HUBS` for auto-instrumentation. Unset means nothing is patched.

`wildedge.get_client()` returns the default client when you need direct
access; `wildedge.set_default_client()` overrides it (useful in tests).
`wildedge.trace` is the exception to the delegation rule: a trace only sets
correlation contextvars and emits nothing, so it never touches or creates a
client.

Auto-instrumentation patches at creation time, so ordering matters: the CLI
patches before any user code runs, an early `init()` covers everything created
after it, and a lazily created client only covers what is created after the
first module-level call. When in doubt, call `init()` at application startup.

## torch and keras

For `torch` and `keras`, models are user-defined subclasses with no constructor to patch. Use `client.load()` to get load, unload, and inference tracking automatically:
Expand Down Expand Up @@ -296,6 +332,30 @@ with client.trace(run_id="run-123", agent_id="agent-1"):
handle.track_inference(duration_ms=t.elapsed_ms, input_modality="text", output_modality="generation")
```

Both are also available module-level on the default client, so this is
equivalent without holding a client:

```python
with wildedge.trace(run_id="run-123", agent_id="agent-1"):
with wildedge.span(kind="agent_step", name="plan", step_index=1):
...
```

### Recording results on an open span

The span object is mutable until the block exits; set outcomes you only know
mid-block directly on it. `set_attributes()` merges into the span's
attributes, `fail()` marks it failed with an optional summary, and an
exception escaping the block sets `status="error"` automatically:

```python
with wildedge.span(kind="eval", name="lint") as span:
errors = lint(result)
span.set_attributes(lint_errors=len(errors))
if errors:
span.fail(f"{len(errors)} lint errors")
```

If you need to set correlation fields without emitting a span event, use the
lower-level `span_context()` directly:

Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ def reset_hardware_sampler():
stop_sampler()


@pytest.fixture(autouse=True)
def reset_default_client():
from wildedge.defaults import set_default_client

set_default_client(None)
yield
set_default_client(None)


PLATFORM_MARKS = {
"requires_linux": "linux",
"requires_macos": "darwin",
Expand Down
29 changes: 29 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,32 @@ def test_parse_run_args_only_double_dash_raises():
"""['--'] with nothing after raises ValueError."""
with pytest.raises(ValueError, match="missing command"):
cli.parse_run_args(["--"])


def test_install_runtime_registers_default_client(monkeypatch):
from wildedge.defaults import peek_default_client

class FakeWildEdge:
SUPPORTED_INTEGRATIONS = {"onnx"}

def __init__(self, *, dsn, app_version, debug, sampling_interval_s=None): # type: ignore[no-untyped-def]
pass

def instrument(self, name): # type: ignore[no-untyped-def]
pass

def flush(self, timeout): # type: ignore[no-untyped-def]
pass

def close(self): # type: ignore[no-untyped-def]
pass

monkeypatch.setattr(bootstrap, "WildEdge", FakeWildEdge)
monkeypatch.setenv(constants.ENV_DSN, "https://secret@ingest.wildedge.dev/key")
monkeypatch.setattr(bootstrap.importlib.util, "find_spec", lambda _: object())

context = bootstrap.install_runtime()
try:
assert peek_default_client() is context.client
finally:
context.shutdown()
124 changes: 124 additions & 0 deletions tests/test_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

from unittest.mock import MagicMock

import wildedge
import wildedge.defaults as defaults
from wildedge import constants


class DummyClient:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.instrument_calls: list[tuple[str | None, list[str] | None]] = []

def instrument(self, integration, *, hubs=None):
self.instrument_calls.append((integration, hubs))


def test_get_client_lazily_creates_noop_without_dsn(monkeypatch):
monkeypatch.delenv(constants.ENV_DSN, raising=False)
monkeypatch.delenv(constants.ENV_INTEGRATIONS, raising=False)
client = wildedge.get_client()
assert client.noop is True
assert wildedge.get_client() is client


def test_set_default_client_wins():
client = DummyClient()
defaults.set_default_client(client)
assert wildedge.get_client() is client
assert defaults.peek_default_client() is client


def test_peek_does_not_create():
assert defaults.peek_default_client() is None


def test_lazy_client_reads_integrations_env(monkeypatch):
monkeypatch.setattr(defaults, "WildEdge", DummyClient)
monkeypatch.setenv(constants.ENV_INTEGRATIONS, "openai")
monkeypatch.delenv(constants.ENV_HUBS, raising=False)
assert wildedge.get_client().instrument_calls == [("openai", None)]


def test_lazy_client_without_env_instruments_nothing(monkeypatch):
monkeypatch.setattr(defaults, "WildEdge", DummyClient)
monkeypatch.delenv(constants.ENV_INTEGRATIONS, raising=False)
monkeypatch.delenv(constants.ENV_HUBS, raising=False)
assert wildedge.get_client().instrument_calls == []


def test_lazy_client_hubs_only(monkeypatch):
monkeypatch.setattr(defaults, "WildEdge", DummyClient)
monkeypatch.delenv(constants.ENV_INTEGRATIONS, raising=False)
monkeypatch.setenv(constants.ENV_HUBS, "huggingface")
assert wildedge.get_client().instrument_calls == [(None, ["huggingface"])]


def test_lazy_client_env_errors_do_not_raise(monkeypatch):
class Exploding(DummyClient):
def instrument(self, integration, *, hubs=None):
raise ValueError("unknown integration")

monkeypatch.setattr(defaults, "WildEdge", Exploding)
monkeypatch.setenv(constants.ENV_INTEGRATIONS, "bogus")
client = wildedge.get_client()
assert isinstance(client, Exploding)


def test_span_delegates_to_default_client():
client = MagicMock()
defaults.set_default_client(client)
wildedge.span(kind="tool", name="lint", step_index=2)
kwargs = client.span.call_args.kwargs
assert kwargs["kind"] == "tool"
assert kwargs["name"] == "lint"
assert kwargs["step_index"] == 2


def test_track_span_delegates_to_default_client():
client = MagicMock()
defaults.set_default_client(client)
wildedge.track_span(kind="tool", name="call", duration_ms=15, status="ok")
kwargs = client.track_span.call_args.kwargs
assert kwargs["duration_ms"] == 15


def test_register_model_delegates_to_default_client():
client = MagicMock()
defaults.set_default_client(client)
model = object()
wildedge.register_model(model, model_id="org/model", source="openrouter")
args, kwargs = client.register_model.call_args
assert args == (model,)
assert kwargs["model_id"] == "org/model"
assert kwargs["source"] == "openrouter"


def test_flush_delegates_to_default_client():
client = MagicMock()
defaults.set_default_client(client)
wildedge.flush(timeout=2.0)
client.flush.assert_called_once_with(timeout=2.0)


def test_trace_is_pure_context_and_creates_no_client(monkeypatch):
monkeypatch.delenv(constants.ENV_DSN, raising=False)
with wildedge.trace(run_id="r1", agent_id="a1"):
ctx = wildedge.get_trace_context()
assert ctx is not None
assert ctx.run_id == "r1"
assert ctx.agent_id == "a1"
assert wildedge.get_trace_context() is None
assert defaults.peek_default_client() is None


def test_module_api_works_end_to_end_without_dsn(monkeypatch):
monkeypatch.delenv(constants.ENV_DSN, raising=False)
monkeypatch.delenv(constants.ENV_INTEGRATIONS, raising=False)
with wildedge.trace(run_id="r"):
with wildedge.span(kind="eval", name="lint") as span:
span.set_attributes(lint_errors=3)
span.fail("3 lint errors")
assert wildedge.get_client().noop is True
81 changes: 81 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,84 @@ def instrument(self, integration, *, hubs=None):

assert isinstance(client, DummyClient)
assert logs == ["wildedge: init called without integrations or hubs"]


def test_init_reuses_existing_default_client(monkeypatch):
from wildedge.defaults import peek_default_client, set_default_client

calls: list[tuple[str | None, list[str] | None]] = []

class ExistingClient:
def instrument(self, integration, *, hubs=None):
calls.append((integration, hubs))

existing = ExistingClient()
set_default_client(existing)

client = wildedge.init(integrations=["onnx"])

assert client is existing
assert calls == [("onnx", None)]
assert peek_default_client() is existing


def test_init_with_dsn_replaces_default_client(monkeypatch):
from wildedge.defaults import peek_default_client, set_default_client

class ExistingClient:
def instrument(self, integration, *, hubs=None):
raise AssertionError("existing client must not be touched")

class DummyClient:
def __init__(self, **kwargs):
self.kwargs = kwargs

def instrument(self, integration, *, hubs=None):
pass

existing = ExistingClient()
set_default_client(existing)
monkeypatch.setattr(convenience, "WildEdge", DummyClient)

client = wildedge.init(dsn="https://secret@ingest.wildedge.dev/key")

assert isinstance(client, DummyClient)
assert client is not existing
assert peek_default_client() is client


def test_init_reuse_warns_about_ignored_kwargs(monkeypatch):
from wildedge.defaults import set_default_client

class ExistingClient:
def instrument(self, integration, *, hubs=None):
pass

logs: list[str] = []
set_default_client(ExistingClient())
monkeypatch.setattr(
convenience.logger, "warning", lambda msg, *args: logs.append(msg % args)
)

wildedge.init(integrations=["onnx"], app_version="1.0.0")

assert logs == [
"wildedge: init() reusing the existing default client; ignoring app_version"
]


def test_init_registers_new_client_as_default(monkeypatch):
from wildedge.defaults import peek_default_client

class DummyClient:
def __init__(self, **kwargs):
self.kwargs = kwargs

def instrument(self, integration, *, hubs=None):
pass

monkeypatch.setattr(convenience, "WildEdge", DummyClient)

client = wildedge.init(dsn="https://secret@ingest.wildedge.dev/key")

assert peek_default_client() is client
47 changes: 47 additions & 0 deletions tests/test_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,50 @@ def test_nested_spans_correct_parent_chain():
assert inner_ev["parent_span_id"] == outer.span_id
assert outer_ev["span_id"] == outer.span_id
assert outer_ev["parent_span_id"] is None


def test_span_set_attributes_merges():
events: list[dict] = []
client = _FakeClient(events)

with SpanContextManager(
client, kind="eval", name="lint", attributes={"a": 1}
) as span:
span.set_attributes(b=2)
span.set_attributes(a=3)

assert events[0]["attributes"] == {"a": 3, "b": 2}


def test_span_set_attributes_from_none():
events: list[dict] = []
client = _FakeClient(events)

with SpanContextManager(client, kind="eval", name="lint") as span:
span.set_attributes(lint_errors=4)

assert events[0]["attributes"] == {"lint_errors": 4}


def test_span_fail_sets_status_and_summary():
events: list[dict] = []
client = _FakeClient(events)

with SpanContextManager(client, kind="eval", name="lint") as span:
span.fail("4 lint errors")

assert events[0]["status"] == "error"
assert events[0]["output_summary"] == "4 lint errors"


def test_span_fail_without_detail_keeps_summary():
events: list[dict] = []
client = _FakeClient(events)

with SpanContextManager(
client, kind="eval", name="lint", output_summary="kept"
) as span:
span.fail()

assert events[0]["status"] == "error"
assert events[0]["output_summary"] == "kept"
Loading
Loading