diff --git a/.gitignore b/.gitignore
index 468c518..4dcd2c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -215,3 +215,6 @@ __marimo__/
# Streamlit
.streamlit/secrets.toml
internal/
+
+# macOS
+.DS_Store
diff --git a/README.md b/README.md
index 955a221..4c95621 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,8 @@
-# WildEdge Python SDK
+
+
+
+
+
[](https://github.com/wild-edge/wildedge-python/actions/workflows/ci.yml)
[](https://pypi.org/project/wildedge-sdk/)
@@ -40,6 +44,7 @@ Useful flags:
| `--hubs` | Hub trackers to activate: `huggingface`, `torchhub` |
| `--print-startup-report` | Print per-integration status at startup |
| `--strict-integrations` | Fail if a requested integration can't be loaded |
+| `--attachments` | Enable opt-in raw input/output attachment upload |
| `--no-propagate` | Don't pass WildEdge env vars to child processes |
## SDK
@@ -102,8 +107,9 @@ For unsupported frameworks, see [Manual tracking](https://github.com/wild-edge/w
| `app_identity` | `` | Namespace for offline persistence. Set per-app in multi-process workloads (or `WILDEDGE_APP_IDENTITY`) |
| `enable_offline_persistence` | `true` | Persist unsent events to disk and replay on restart |
| `sampling_interval_s` | `30.0` | Seconds between background hardware snapshots. Set to `0` or `None` to disable (or `WILDEDGE_SAMPLING_INTERVAL_S`) |
+| `attachments_enabled` | `false` | Opt-in upload of raw inference inputs/outputs (or `WILDEDGE_ATTACHMENTS_ENABLED`). See [Attachments](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md#attachments) |
-For advanced options (batching, queue tuning, dead-letter storage), see [Configuration](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md).
+For advanced options (batching, queue tuning, dead-letter storage, attachments), see [Configuration](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md).
## Projects using this SDK
@@ -115,9 +121,13 @@ For advanced options (batching, queue tuning, dead-letter storage), see [Configu
Using WildEdge in your project? Open a PR to add it to the list.
-## Privacy
+## Security & Privacy
+
+By default the SDK transmits only anonymized telemetry, never raw model inputs
+or outputs. The one exception is opt-in [attachments](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md#attachments)
+(`attachments_enabled`), which upload raw bytes you explicitly pass in.
-Report security & priact issues to: wildedge@googlegroups.com.
+Report security and privacy issues to: support@wildedge.dev
## Links
diff --git a/docs/configuration.md b/docs/configuration.md
index 566738b..e3cf982 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -22,3 +22,21 @@ Full reference for all `WildEdge` client parameters.
| `max_event_age_sec` | `900` | - | Max age in seconds before an event is dropped |
| `enable_dead_letter_persistence` | `false` | - | Persist dropped batches to disk for later inspection |
| `debug` | `false` | `WILDEDGE_DEBUG` | Log SDK internals to console |
+
+## Attachments
+
+Opt-in upload of raw inference inputs and outputs alongside telemetry. Off by
+default, and requires the attachments feature to be enabled on your project.
+Bytes are buffered to disk and uploaded in the background via presigned URLs;
+event flushes never block on an upload.
+
+| Parameter | Default | Env var | Description |
+|---|---|---|---|
+| `attachments_enabled` | `false` | `WILDEDGE_ATTACHMENTS_ENABLED` | Enable raw input/output upload |
+| `max_attachments_per_inference` | `10` | - | Cap on attachments buffered per inference event |
+| `max_attachment_size_bytes` | `10485760` | - | Per-attachment size limit (10 MB); larger ones are dropped |
+| `attachment_storage_strategy` | `"file"` | - | `"file"` (bytes on disk) or `"inline"` (small blobs embedded in the record) |
+| `attachment_filter` | `None` | - | `Callable[[list[Attachment]], list[Attachment]]` to redact/drop before buffering |
+| `attachment_dir` | per `app_identity` | - | Override the local buffer directory |
+
+See [Track attachments](manual-tracking.md#track-attachments) for usage.
diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md
index 0fa227e..8968c16 100644
--- a/docs/manual-tracking.md
+++ b/docs/manual-tracking.md
@@ -216,6 +216,42 @@ handle.feedback(FeedbackType.THUMBS_DOWN)
`FeedbackType` values: `THUMBS_UP`, `THUMBS_DOWN`.
+## Track attachments
+
+Opt-in upload of the raw bytes behind an inference, such as the source image,
+audio clip, or generated text, so you can inspect or curate them later. This is
+off by default and requires the attachment feature enabled on your project.
+Enable it at init, then pass `attachments=` to `track_inference`:
+
+```python
+import wildedge
+from wildedge import Attachment
+
+client = wildedge.init(attachments_enabled=True)
+handle = client.register_model(model, model_id="doc-classifier-v1")
+
+handle.track_inference(
+ duration_ms=120,
+ input_modality="image",
+ output_modality="text",
+ attachments=[
+ Attachment(content_type="image/jpeg", role="input", data=image_bytes),
+ Attachment(content_type="text/plain", role="output", data=answer.encode()),
+ ],
+)
+```
+
+An `Attachment` carries either in-memory `data` or a file `path`, plus a
+`role` (`"input"` or `"output"`) and `content_type`. The SDK writes a reference
+into the inference event immediately and uploads the bytes in the background via
+a presigned URL. A failed or disabled upload never blocks telemetry.
+
+Bytes are buffered to disk and survive restarts. Capture is gated by
+`max_attachments_per_inference`, `max_attachment_size_bytes`, and an optional
+`attachment_filter` hook. See [Configuration](configuration.md#attachments) and
+[`examples/attachments_example.py`](../examples/attachments_example.py) for the
+full reference.
+
## Track spans for agentic workflows
Use span events to track non-inference steps like planning, tool calls, retrieval, or memory updates.
diff --git a/docs/wildedge-logo-text.svg b/docs/wildedge-logo-text.svg
new file mode 100644
index 0000000..7a61d3d
--- /dev/null
+++ b/docs/wildedge-logo-text.svg
@@ -0,0 +1,99 @@
+
+
+
+
diff --git a/examples/attachments_example.py b/examples/attachments_example.py
new file mode 100644
index 0000000..4eb0735
--- /dev/null
+++ b/examples/attachments_example.py
@@ -0,0 +1,62 @@
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["wildedge-sdk"]
+#
+# [tool.uv.sources]
+# wildedge-sdk = { path = "..", editable = true }
+# ///
+"""
+Attachment upload example. Run with: uv run attachments_example.py
+
+Opt-in raw input/output capture. When `attachments_enabled=True` (and the
+project has the paid feature turned on), the SDK buffers the raw bytes locally,
+writes a reference into the inference event, and uploads the bytes independently
+via a presigned URL, so the batch flush never waits on the upload.
+
+Attachments are off by default and must be explicitly enabled. Set WILDEDGE_DSN
+to see real uploads; otherwise the client runs in no-op mode.
+"""
+
+import wildedge
+from wildedge import Attachment
+
+
+# Optional: redact / drop attachments before they are buffered.
+def redact(attachments: list[Attachment]) -> list[Attachment]:
+ return [a for a in attachments if a.content_type != "application/secret"]
+
+
+client = wildedge.init(
+ app_version="1.0.0",
+ attachments_enabled=True,
+ max_attachments_per_inference=5,
+ max_attachment_size_bytes=5 * 1024 * 1024,
+ attachment_storage_strategy="file", # or "inline" for small blobs
+ attachment_filter=redact,
+)
+
+handle = client.register_model(
+ object(),
+ model_id="doc-classifier-v1",
+ source="local",
+ family="custom",
+)
+
+# Pretend these came from a real inference call.
+image_bytes = b"\xff\xd8\xff\xe0fake-jpeg-bytes"
+answer = "This document is an invoice."
+
+inference_id = handle.track_inference(
+ duration_ms=120,
+ input_modality="image",
+ output_modality="text",
+ attachments=[
+ Attachment(content_type="image/jpeg", role="input", data=image_bytes),
+ Attachment(content_type="text/plain", role="output", data=answer.encode()),
+ ],
+)
+
+print(f"tracked inference {inference_id[:8]}… with 2 attachments")
+
+# Bytes upload in the background; flush/close lets buffered events drain.
+client.close()
diff --git a/pyproject.toml b/pyproject.toml
index a1257ad..197d73c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "wildedge-sdk"
-version = "0.1.4"
-description = "On-device ML inference monitoring for Python"
+version = "0.1.5"
+description = "ML inference monitoring for Python: on-device and remote models"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
diff --git a/tests/test_attachments.py b/tests/test_attachments.py
new file mode 100644
index 0000000..46ff061
--- /dev/null
+++ b/tests/test_attachments.py
@@ -0,0 +1,480 @@
+from __future__ import annotations
+
+import json
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+
+from wildedge.attachments import (
+ Attachment,
+ AttachmentManager,
+ AttachmentStore,
+ AttachmentTransmitter,
+ AttachmentUploader,
+ PendingAttachment,
+ UploadOutcome,
+)
+from wildedge.events.inference import InferenceEvent
+from wildedge.model import ModelHandle, ModelInfo
+
+# --------------------------------------------------------------------------- #
+# AttachmentStore
+# --------------------------------------------------------------------------- #
+
+
+def _append(store: AttachmentStore, data: bytes, attachment_id: str, role="input"):
+ store.append(
+ attachment=Attachment(content_type="text/plain", role=role, data=data),
+ attachment_id=attachment_id,
+ inference_id="inf-1",
+ inference_timestamp="2026-01-17T10:30:00+00:00",
+ data=data,
+ )
+
+
+def test_store_file_strategy_roundtrip(tmp_path):
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ _append(store, b"hello", "a1")
+ assert store.length() == 1
+ pending = store.peek_many(10)
+ assert len(pending) == 1
+ assert pending[0].attachment_id == "a1"
+ assert pending[0].read_bytes() == b"hello"
+ assert pending[0].bin_path is not None and pending[0].bin_path.exists()
+
+
+def test_store_inline_strategy_roundtrip(tmp_path):
+ store = AttachmentStore(str(tmp_path), strategy="inline")
+ _append(store, b"\x00\x01\x02inline", "a1")
+ pending = store.peek_many(10)
+ assert pending[0].read_bytes() == b"\x00\x01\x02inline"
+ assert pending[0].bin_path is None
+
+
+def test_store_survives_restart(tmp_path):
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ _append(store, b"persisted", "a1")
+ # Fresh instance reads the same directory.
+ reopened = AttachmentStore(str(tmp_path), strategy="file")
+ pending = reopened.peek_many(10)
+ assert len(pending) == 1
+ assert pending[0].read_bytes() == b"persisted"
+
+
+def test_store_capacity_drops_oldest(tmp_path):
+ store = AttachmentStore(str(tmp_path), strategy="file", max_pending=2)
+ _append(store, b"1", "a1")
+ _append(store, b"2", "a2")
+ _append(store, b"3", "a3")
+ assert store.length() == 2
+ ids = [p.attachment_id for p in store.peek_many(10)]
+ assert ids == ["a2", "a3"]
+ # Oldest bin file is gone.
+ assert not (tmp_path / "a1.bin").exists()
+
+
+def test_store_remove_deletes_record_and_bin(tmp_path):
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ _append(store, b"x", "a1")
+ pending = store.peek_many(10)
+ store.remove(pending)
+ assert store.length() == 0
+ assert not (tmp_path / "a1.bin").exists()
+
+
+def test_store_peek_oldest_first(tmp_path):
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ for i in range(5):
+ _append(store, str(i).encode(), f"a{i}")
+ ids = [p.attachment_id for p in store.peek_many(3)]
+ assert ids == ["a0", "a1", "a2"]
+
+
+# --------------------------------------------------------------------------- #
+# AttachmentManager (capture)
+# --------------------------------------------------------------------------- #
+
+
+def _manager(tmp_path, **kwargs):
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ return store, AttachmentManager(store, **kwargs)
+
+
+def test_manager_capture_returns_refs_and_buffers(tmp_path):
+ store, mgr = _manager(tmp_path)
+ refs = mgr.capture(
+ [
+ Attachment(content_type="image/jpeg", role="input", data=b"img"),
+ Attachment(content_type="text/plain", role="output", data=b"out"),
+ ],
+ "inf-1",
+ datetime.now(timezone.utc),
+ )
+ assert len(refs) == 2
+ assert refs[0] == {
+ "attachment_id": refs[0]["attachment_id"],
+ "role": "input",
+ "content_type": "image/jpeg",
+ }
+ assert store.length() == 2
+
+
+def test_manager_disabled_returns_nothing(tmp_path):
+ store, mgr = _manager(tmp_path)
+ mgr.disable()
+ refs = mgr.capture(
+ [Attachment(content_type="text/plain", data=b"x")],
+ "inf-1",
+ datetime.now(timezone.utc),
+ )
+ assert refs == []
+ assert store.length() == 0
+
+
+def test_manager_enforces_max_per_inference(tmp_path):
+ store, mgr = _manager(tmp_path, max_per_inference=2)
+ refs = mgr.capture(
+ [Attachment(content_type="text/plain", data=b"x") for _ in range(5)],
+ "inf-1",
+ datetime.now(timezone.utc),
+ )
+ assert len(refs) == 2
+ assert store.length() == 2
+
+
+def test_manager_drops_oversized(tmp_path):
+ store, mgr = _manager(tmp_path, max_size_bytes=4)
+ refs = mgr.capture(
+ [
+ Attachment(content_type="text/plain", data=b"ok"),
+ Attachment(content_type="text/plain", data=b"way too big"),
+ ],
+ "inf-1",
+ datetime.now(timezone.utc),
+ )
+ assert len(refs) == 1
+ assert store.length() == 1
+
+
+def test_manager_filter_hook(tmp_path):
+ store, mgr = _manager(
+ tmp_path,
+ attachment_filter=lambda atts: [a for a in atts if a.role == "input"],
+ )
+ refs = mgr.capture(
+ [
+ Attachment(content_type="text/plain", role="input", data=b"keep"),
+ Attachment(content_type="text/plain", role="output", data=b"drop"),
+ ],
+ "inf-1",
+ datetime.now(timezone.utc),
+ )
+ assert len(refs) == 1
+ assert refs[0]["role"] == "input"
+
+
+# --------------------------------------------------------------------------- #
+# InferenceEvent attachment refs
+# --------------------------------------------------------------------------- #
+
+
+def test_inference_event_emits_attachments():
+ event = InferenceEvent(
+ model_id="m1",
+ duration_ms=10,
+ attachments=[
+ {"attachment_id": "a1", "role": "input", "content_type": "image/jpeg"}
+ ],
+ )
+ data = event.to_dict()
+ assert data["attachments"] == [
+ {"attachment_id": "a1", "role": "input", "content_type": "image/jpeg"}
+ ]
+
+
+def test_inference_event_omits_attachments_when_absent():
+ event = InferenceEvent(model_id="m1", duration_ms=10)
+ assert "attachments" not in event.to_dict()
+
+
+def test_text_input_meta_no_has_attachments():
+ from wildedge.events.inference import TextInputMeta
+
+ assert not hasattr(TextInputMeta(char_count=1), "has_attachments")
+
+
+# --------------------------------------------------------------------------- #
+# ModelHandle wiring
+# --------------------------------------------------------------------------- #
+
+
+def _make_handle(tmp_path):
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ mgr = AttachmentManager(store)
+ published: list[dict] = []
+ info = ModelInfo(
+ model_name="m", model_version="1", model_source="local", model_format="onnx"
+ )
+ handle = ModelHandle("m1", info, published.append, capture_attachments=mgr.capture)
+ return handle, store, published
+
+
+def test_track_inference_wires_attachments(tmp_path):
+ handle, store, published = _make_handle(tmp_path)
+ handle.track_inference(
+ duration_ms=10,
+ input_modality="image",
+ attachments=[Attachment(content_type="image/jpeg", role="input", data=b"img")],
+ )
+ assert store.length() == 1
+ event = published[0]
+ assert len(event["attachments"]) == 1
+ assert event["attachments"][0]["content_type"] == "image/jpeg"
+ # The buffered attachment is keyed to this inference event.
+ assert store.peek_many(1)[0].inference_id == event["inference"]["inference_id"]
+
+
+def test_track_inference_without_capture_callback_omits_refs(tmp_path):
+ info = ModelInfo(
+ model_name="m", model_version="1", model_source="local", model_format="onnx"
+ )
+ published: list[dict] = []
+ handle = ModelHandle("m1", info, published.append) # no capture callback
+ handle.track_inference(
+ duration_ms=10,
+ attachments=[Attachment(content_type="image/jpeg", data=b"img")],
+ )
+ assert "attachments" not in published[0]
+
+
+# --------------------------------------------------------------------------- #
+# AttachmentTransmitter (presign + PUT) with a fake urlopen
+# --------------------------------------------------------------------------- #
+
+
+class _Resp:
+ def __init__(self, status: int, body: bytes = b""):
+ self.status = status
+ self._body = body
+
+ def read(self) -> bytes:
+ return self._body
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ return False
+
+
+def _fake_urlopen(responses):
+ """responses: list of _Resp or Exception, consumed in call order."""
+ calls = iter(responses)
+
+ def opener(req, timeout=None):
+ item = next(calls)
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+ return opener
+
+
+PRESIGN_OK = _Resp(
+ 200,
+ json.dumps([{"upload_url": "https://blob/put", "attachment_id": "a1"}]).encode(),
+)
+
+
+def _pending(tmp_path) -> PendingAttachment:
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ _append(store, b"payload", "a1")
+ return store.peek_many(1)[0]
+
+
+def _transmit(monkeypatch, tmp_path, responses) -> UploadOutcome:
+ monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen(responses))
+ tx = AttachmentTransmitter(api_key="secret", host="https://ingest.example")
+ return tx.transmit(_pending(tmp_path))
+
+
+def test_transmit_success(monkeypatch, tmp_path):
+ outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, _Resp(200)])
+ assert outcome is UploadOutcome.UPLOADED
+
+
+def test_transmit_put_204(monkeypatch, tmp_path):
+ outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, _Resp(204)])
+ assert outcome is UploadOutcome.UPLOADED
+
+
+def test_presign_403_feature_disabled(monkeypatch, tmp_path):
+ err = urllib.error.HTTPError("u", 403, "forbidden", {}, None)
+ outcome = _transmit(monkeypatch, tmp_path, [err])
+ assert outcome is UploadOutcome.FEATURE_DISABLED
+
+
+def test_presign_422_permanent(monkeypatch, tmp_path):
+ err = urllib.error.HTTPError("u", 422, "bad", {}, None)
+ outcome = _transmit(monkeypatch, tmp_path, [err])
+ assert outcome is UploadOutcome.PERMANENT
+
+
+def test_presign_429_transient(monkeypatch, tmp_path):
+ err = urllib.error.HTTPError("u", 429, "rate", {}, None)
+ outcome = _transmit(monkeypatch, tmp_path, [err])
+ assert outcome is UploadOutcome.TRANSIENT
+
+
+def test_presign_500_transient(monkeypatch, tmp_path):
+ err = urllib.error.HTTPError("u", 500, "boom", {}, None)
+ outcome = _transmit(monkeypatch, tmp_path, [err])
+ assert outcome is UploadOutcome.TRANSIENT
+
+
+def test_presign_network_error_transient(monkeypatch, tmp_path):
+ outcome = _transmit(monkeypatch, tmp_path, [urllib.error.URLError("offline")])
+ assert outcome is UploadOutcome.TRANSIENT
+
+
+def test_put_expired_signature_transient(monkeypatch, tmp_path):
+ # Blob store returns 403 on an expired URL -> retry (re-presign next tick).
+ err = urllib.error.HTTPError("u", 403, "expired", {}, None)
+ outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, err])
+ assert outcome is UploadOutcome.TRANSIENT
+
+
+def test_put_400_permanent(monkeypatch, tmp_path):
+ err = urllib.error.HTTPError("u", 400, "bad", {}, None)
+ outcome = _transmit(monkeypatch, tmp_path, [PRESIGN_OK, err])
+ assert outcome is UploadOutcome.PERMANENT
+
+
+# --------------------------------------------------------------------------- #
+# AttachmentUploader
+# --------------------------------------------------------------------------- #
+
+
+class _StubTransmitter:
+ def __init__(self, outcomes):
+ self.outcomes = list(outcomes)
+ self.seen: list[str] = []
+
+ def transmit(self, pending):
+ self.seen.append(pending.attachment_id)
+ return self.outcomes.pop(0)
+
+
+def _filled_store(tmp_path, n=3):
+ store = AttachmentStore(str(tmp_path), strategy="file")
+ for i in range(n):
+ _append(store, str(i).encode(), f"a{i}")
+ return store
+
+
+def test_uploader_uploads_and_removes(tmp_path):
+ store = _filled_store(tmp_path, 3)
+ tx = _StubTransmitter([UploadOutcome.UPLOADED] * 3)
+ up = AttachmentUploader(store, tx)
+ up.process_pending()
+ assert store.length() == 0
+ assert up.uploaded_count == 3
+
+
+def test_uploader_stops_at_transient(tmp_path):
+ store = _filled_store(tmp_path, 3)
+ tx = _StubTransmitter(
+ [UploadOutcome.UPLOADED, UploadOutcome.TRANSIENT, UploadOutcome.UPLOADED]
+ )
+ up = AttachmentUploader(store, tx)
+ up.process_pending()
+ # First removed, second hit transient -> stop; a1 and a2 remain.
+ ids = [p.attachment_id for p in store.peek_many(10)]
+ assert ids == ["a1", "a2"]
+ assert up.uploaded_count == 1
+
+
+def test_uploader_drops_on_permanent(tmp_path):
+ store = _filled_store(tmp_path, 2)
+ tx = _StubTransmitter([UploadOutcome.PERMANENT, UploadOutcome.UPLOADED])
+ up = AttachmentUploader(store, tx)
+ up.process_pending()
+ assert store.length() == 0
+ assert up.uploaded_count == 1
+ assert up.dropped_count == 1
+
+
+def test_uploader_feature_disabled_clears_and_callbacks(tmp_path):
+ store = _filled_store(tmp_path, 3)
+ tx = _StubTransmitter([UploadOutcome.FEATURE_DISABLED])
+ disabled = []
+ up = AttachmentUploader(
+ store, tx, on_feature_disabled=lambda: disabled.append(True)
+ )
+ up.process_pending()
+ assert disabled == [True]
+ assert store.length() == 0
+
+
+def test_uploader_evicts_stale(tmp_path):
+ store = _filled_store(tmp_path, 2)
+ tx = _StubTransmitter([UploadOutcome.UPLOADED] * 2)
+ up = AttachmentUploader(store, tx, max_age_s=0.0)
+ time.sleep(0.01)
+ up.process_pending()
+ assert store.length() == 0
+ assert up.dropped_count == 2
+ assert up.uploaded_count == 0 # all evicted before upload
+ assert tx.seen == []
+
+
+# --------------------------------------------------------------------------- #
+# Client wiring
+# --------------------------------------------------------------------------- #
+
+
+def test_client_attachments_disabled_by_default(monkeypatch):
+ from wildedge import constants
+ from wildedge.client import WildEdge
+
+ monkeypatch.delenv(constants.ENV_ATTACHMENTS_ENABLED, raising=False)
+ client = WildEdge(dsn="https://test@test.com/key")
+ try:
+ assert client.attachment_manager is None
+ assert client.attachment_uploader is None
+ finally:
+ client.close()
+
+
+def test_client_attachments_enabled_wires_capture(monkeypatch, tmp_path):
+ from wildedge.client import WildEdge
+
+ client = WildEdge(
+ dsn="https://test@test.com/key",
+ attachments_enabled=True,
+ attachment_dir=str(tmp_path),
+ sampling_interval_s=0,
+ )
+ try:
+ assert client.attachment_manager is not None
+ handle = client.register_model(object(), model_id="m1", source="local")
+ assert handle.capture_attachments is not None
+ finally:
+ client.close()
+
+
+def test_client_attachments_enabled_via_env(monkeypatch, tmp_path):
+ from wildedge import constants
+ from wildedge.client import WildEdge
+
+ monkeypatch.setenv(constants.ENV_ATTACHMENTS_ENABLED, "1")
+ client = WildEdge(
+ dsn="https://test@test.com/key",
+ attachment_dir=str(tmp_path),
+ sampling_interval_s=0,
+ )
+ try:
+ assert client.attachment_manager is not None
+ finally:
+ client.close()
diff --git a/tests/test_device.py b/tests/test_device.py
index 70fa84d..fa1671b 100644
--- a/tests/test_device.py
+++ b/tests/test_device.py
@@ -3,6 +3,7 @@
from pathlib import Path
from unittest.mock import patch
+from wildedge import constants
from wildedge.platforms import detect_device, get_device_id_path
from wildedge.platforms.base import Platform, hmac_device_id
from wildedge.platforms.device_info import DeviceInfo
@@ -35,7 +36,7 @@ def test_returns_device_info(self, monkeypatch):
info = detect_device(api_key="test-key", app_version="1.0.0")
assert isinstance(info, DeviceInfo)
assert info.app_version == "1.0.0"
- assert info.sdk_version == "wildedge-python-0.1.0"
+ assert info.sdk_version == constants.SDK_VERSION
def test_device_type_is_normalised(self, monkeypatch):
monkeypatch.setattr(
diff --git a/tests/test_hardware_context.py b/tests/test_hardware_context.py
index d35f0a8..0240818 100644
--- a/tests/test_hardware_context.py
+++ b/tests/test_hardware_context.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from wildedge.events.inference import InferenceEvent
+from wildedge.model import ModelHandle, ModelInfo
from wildedge.platforms import capture_hardware
from wildedge.platforms.hardware import HardwareContext, ThermalContext
from wildedge.platforms.linux import LinuxPlatform
@@ -81,3 +82,30 @@ def test_capture_preserves_existing_accelerator_when_not_passed(monkeypatch):
lambda self: HardwareContext(accelerator_actual="mps"),
)
assert capture_hardware().accelerator_actual == "mps"
+
+
+def test_track_inference_passes_detected_accelerator_to_hardware(monkeypatch):
+ """detected_accelerator on the handle must appear as accelerator_actual in the event."""
+ monkeypatch.setattr("wildedge.model.is_sampling", lambda: True)
+ monkeypatch.setattr(
+ "wildedge.model.capture_hardware",
+ lambda accelerator_actual=None: HardwareContext(
+ accelerator_actual=accelerator_actual
+ ),
+ )
+
+ published = []
+ info = ModelInfo(
+ model_name="test",
+ model_format="pytorch",
+ model_version="1",
+ model_source="local",
+ )
+ handle = ModelHandle(model_id="m1", info=info, publish=published.append)
+ handle.detected_accelerator = "cuda"
+
+ handle.track_inference(duration_ms=50)
+
+ assert len(published) == 1
+ hardware = published[0]["inference"].get("hardware", {})
+ assert hardware.get("accelerator_actual") == "cuda"
diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py
index 168e98d..05a1de4 100644
--- a/tests/test_platform_adapters.py
+++ b/tests/test_platform_adapters.py
@@ -2,11 +2,29 @@
from wildedge.platforms.base import cuda_device_count
from wildedge.platforms.linux import LinuxPlatform
-from wildedge.platforms.macos import MacOSPlatform
+from wildedge.platforms.macos import MACOS_THERMAL_STATES, MacOSPlatform
from wildedge.platforms.unknown import UnknownPlatform
from wildedge.platforms.windows import WindowsPlatform
+class FakeObjc:
+ """Minimal stand-in for the libobjc CDLL used in MacOSPlatform.thermal().
+
+ Absorbs restype/argtypes assignments silently and returns a no-op callable
+ for any attribute not explicitly defined.
+ """
+
+ objc_getClass = staticmethod(lambda name: 1) # noqa: N815
+ sel_registerName = staticmethod(lambda name: 2) # noqa: N815
+ objc_msgSend = staticmethod(lambda *a: 3) # noqa: N815
+
+ def __getattr__(self, name):
+ return lambda *a, **kw: 0
+
+ def __setattr__(self, name, val):
+ pass
+
+
def test_linux_gpu_accelerators_cuda_and_rocm_counts(monkeypatch):
platform = LinuxPlatform()
monkeypatch.setattr("wildedge.platforms.linux.cuda_device_count", lambda _: 1)
@@ -116,6 +134,93 @@ def test_unknown_os_version_returns_platform_version(monkeypatch):
assert UnknownPlatform().os_version() == "some-kernel-version"
+def test_macos_thermal_states_covers_all_levels():
+ assert set(MACOS_THERMAL_STATES) == {0, 1, 2, 3}
+ states = {v[0] for v in MACOS_THERMAL_STATES.values()}
+ assert states == {"nominal", "fair", "serious", "critical"}
+
+
+def test_macos_cpu_freq_intel(monkeypatch):
+ """On Intel, both hw.cpufrequency and hw.cpufrequency_max are available."""
+ import wildedge.platforms.macos as macos_mod
+
+ calls = []
+
+ def fake_sysctl(name):
+ calls.append(name)
+ return {
+ b"hw.cpufrequency": 2_400_000_000,
+ b"hw.cpufrequency_max": 3_200_000_000,
+ }.get(name)
+
+ monkeypatch.setattr(macos_mod, "_sysctl_uint64", fake_sysctl)
+ cur, max_f = MacOSPlatform().cpu_freq()
+ assert cur == 2400
+ assert max_f == 3200
+
+
+def test_macos_cpu_freq_apple_silicon(monkeypatch):
+ """On Apple Silicon, hw.cpufrequency is absent; max falls back to hw.perflevel0.cpufrequency_max."""
+ import wildedge.platforms.macos as macos_mod
+
+ def fake_sysctl(name):
+ return {b"hw.perflevel0.cpufrequency_max": 4_056_000_000}.get(name)
+
+ monkeypatch.setattr(macos_mod, "_sysctl_uint64", fake_sysctl)
+ cur, max_f = MacOSPlatform().cpu_freq()
+ assert cur is None
+ assert max_f == 4056
+
+
+def test_macos_thermal_nominal(monkeypatch):
+ import wildedge.platforms.macos as macos_mod
+
+ monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc())
+ monkeypatch.setattr(
+ macos_mod.ctypes,
+ "CFUNCTYPE",
+ lambda *types: lambda fn: lambda *a: 0, # always returns level 0 (nominal)
+ )
+ monkeypatch.setattr(
+ macos_mod.ctypes, "cast", lambda obj, t: type("V", (), {"value": 0})()
+ )
+
+ ctx = MacOSPlatform().thermal()
+ assert ctx is not None
+ assert ctx.state == "nominal"
+
+
+def test_macos_thermal_serious(monkeypatch):
+ import wildedge.platforms.macos as macos_mod
+
+ monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc())
+ monkeypatch.setattr(
+ macos_mod.ctypes,
+ "CFUNCTYPE",
+ lambda *types: lambda fn: lambda *a: 2, # level 2 = serious
+ )
+ monkeypatch.setattr(
+ macos_mod.ctypes, "cast", lambda obj, t: type("V", (), {"value": 0})()
+ )
+
+ ctx = MacOSPlatform().thermal()
+ assert ctx is not None
+ assert ctx.state == "serious"
+ assert ctx.state_raw == "serious"
+
+
+def test_macos_thermal_returns_none_on_error(monkeypatch):
+ import wildedge.platforms.macos as macos_mod
+
+ monkeypatch.setattr(
+ macos_mod.ctypes.cdll,
+ "LoadLibrary",
+ lambda _: (_ for _ in ()).throw(OSError("no objc")),
+ )
+
+ assert MacOSPlatform().thermal() is None
+
+
def test_platform_adapters_expose_state_and_cache_paths():
for adapter in (
LinuxPlatform(),
diff --git a/tests/test_platform_macos.py b/tests/test_platform_macos.py
index 9506321..096030c 100644
--- a/tests/test_platform_macos.py
+++ b/tests/test_platform_macos.py
@@ -2,6 +2,7 @@
import pytest
+from wildedge.platforms.hardware import ThermalContext
from wildedge.platforms.macos import MacOSPlatform
@@ -42,12 +43,20 @@ def test_ram_bytes_delegates_to_meminfo(monkeypatch):
def test_hardware_context_fields(monkeypatch):
monkeypatch.setattr(MacOSPlatform, "meminfo", lambda self: (None, 3_000_000_000))
monkeypatch.setattr(MacOSPlatform, "battery", lambda self: (0.8, False))
+ monkeypatch.setattr(MacOSPlatform, "cpu_freq", lambda self: (None, 4056))
+ monkeypatch.setattr(
+ MacOSPlatform,
+ "thermal",
+ lambda self: ThermalContext(state="fair", state_raw="fair"),
+ )
ctx = MacOSPlatform().hardware_context()
assert ctx.memory_available_bytes == 3_000_000_000
assert ctx.battery_level == pytest.approx(0.8)
assert ctx.battery_charging is False
assert ctx.cpu_freq_mhz is None
- assert ctx.thermal is None
+ assert ctx.cpu_freq_max_mhz == 4056
+ assert ctx.thermal is not None
+ assert ctx.thermal.state == "fair"
@pytest.mark.requires_macos
diff --git a/uv.lock b/uv.lock
index 58a4ccf..ca702ca 100644
--- a/uv.lock
+++ b/uv.lock
@@ -419,7 +419,7 @@ wheels = [
[[package]]
name = "wildedge-sdk"
-version = "0.1.3"
+version = "0.1.5"
source = { editable = "." }
[package.dev-dependencies]
diff --git a/wildedge/__init__.py b/wildedge/__init__.py
index 2b7993f..a2d68b9 100644
--- a/wildedge/__init__.py
+++ b/wildedge/__init__.py
@@ -1,5 +1,6 @@
"""WildEdge Python SDK."""
+from wildedge.attachments import Attachment
from wildedge.client import SpanContextManager, WildEdge
from wildedge.convenience import init
from wildedge.decorators import track
@@ -35,6 +36,7 @@
__all__ = [
"WildEdge",
"init",
+ "Attachment",
"capture_hardware",
"HardwareContext",
"ThermalContext",
diff --git a/wildedge/attachments.py b/wildedge/attachments.py
new file mode 100644
index 0000000..9636120
--- /dev/null
+++ b/wildedge/attachments.py
@@ -0,0 +1,539 @@
+"""Opt-in raw input/output attachment upload.
+
+Capture and upload are decoupled, mirroring the Swift SDK:
+
+ track_inference(attachments=[...]) AttachmentUploader (own thread)
+ -> generate attachment_id -> POST /api/attachments/presign
+ -> AttachmentStore.append(bytes + meta) -> PUT bytes to presigned URL
+ -> event["attachments"] = [refs] -> delete local bytes on success
+
+The batch flush never waits on bytes: the reference is written into the
+inference event immediately, while the bytes ride this module's independent
+loop. Only collect attachments for events guaranteed to be transmitted - with
+reservoir sampling inactive every inference event is transmitted, so every
+attachment is eligible.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import threading
+import time
+import urllib.error
+import urllib.request
+import uuid
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+from wildedge import constants
+from wildedge.logging import logger
+
+AttachmentStorageStrategy = str # "file" | "inline"
+
+
+@dataclass
+class Attachment:
+ """A raw input or output blob to upload alongside an inference event.
+
+ Provide exactly one of ``data`` (in-memory bytes) or ``path`` (a file on
+ disk). ``content_type`` is the MIME type baked into the presigned URL and
+ echoed in the inference event reference.
+ """
+
+ content_type: str
+ role: str = "input" # "input" | "output"
+ data: bytes | None = None
+ path: str | None = None
+ name: str | None = None
+
+ def resolve_bytes(self) -> bytes:
+ if self.data is not None:
+ return self.data
+ if self.path is not None:
+ return Path(self.path).expanduser().read_bytes()
+ raise ValueError("Attachment requires either data or path")
+
+ def size_bytes(self) -> int:
+ if self.data is not None:
+ return len(self.data)
+ if self.path is not None:
+ return Path(self.path).expanduser().stat().st_size
+ raise ValueError("Attachment requires either data or path")
+
+
+@dataclass
+class PendingAttachment:
+ """A buffered attachment awaiting upload, reconstructed from disk."""
+
+ attachment_id: str
+ inference_id: str
+ role: str
+ content_type: str
+ size_bytes: int
+ inference_timestamp: str
+ registered_at: float
+ record_path: Path
+ bin_path: Path | None = None
+ inline_data: bytes | None = None
+ name: str | None = None
+
+ def read_bytes(self) -> bytes:
+ if self.inline_data is not None:
+ return self.inline_data
+ if self.bin_path is not None:
+ return self.bin_path.read_bytes()
+ raise ValueError("PendingAttachment has no payload")
+
+
+class UploadOutcome(Enum):
+ UPLOADED = "uploaded"
+ TRANSIENT = "transient"
+ PERMANENT = "permanent"
+ FEATURE_DISABLED = "feature_disabled"
+
+
+class AttachmentStore:
+ """Disk-backed, restart-safe buffer of pending attachments.
+
+ Each attachment is one JSON record file (timestamp-ordered name for
+ oldest-first draining); ``file`` strategy also writes a sibling ``.bin``
+ holding the raw bytes, while ``inline`` embeds base64 bytes in the record.
+ """
+
+ def __init__(
+ self,
+ directory: str,
+ *,
+ strategy: AttachmentStorageStrategy = constants.DEFAULT_ATTACHMENT_STORAGE_STRATEGY,
+ max_pending: int = constants.DEFAULT_MAX_PENDING_ATTACHMENTS,
+ ) -> None:
+ self.directory = Path(directory).expanduser()
+ self.strategy = strategy
+ self.max_pending = max_pending
+ self.lock = threading.Lock()
+ self._seq = 0
+ self.directory.mkdir(parents=True, exist_ok=True)
+
+ def append(
+ self,
+ *,
+ attachment: Attachment,
+ attachment_id: str,
+ inference_id: str,
+ inference_timestamp: str,
+ data: bytes,
+ ) -> None:
+ with self.lock:
+ self._evict_for_capacity()
+ record: dict[str, Any] = {
+ "attachment_id": attachment_id,
+ "inference_id": inference_id,
+ "role": attachment.role,
+ "content_type": attachment.content_type,
+ "size_bytes": len(data),
+ "inference_timestamp": inference_timestamp,
+ "registered_at": time.time(),
+ "name": attachment.name,
+ }
+ if self.strategy == "inline":
+ record["payload_kind"] = "inline"
+ record["data_b64"] = base64.b64encode(data).decode("ascii")
+ else:
+ bin_path = self.directory / f"{attachment_id}.bin"
+ bin_path.write_bytes(data)
+ record["payload_kind"] = "file"
+ record["bin_file"] = bin_path.name
+
+ name = f"{time.time_ns():020d}-{self._seq:010d}-{attachment_id}.json"
+ self._seq += 1
+ (self.directory / name).write_text(
+ json.dumps(record, separators=(",", ":"), ensure_ascii=True)
+ )
+
+ def _evict_for_capacity(self) -> None:
+ # Caller holds the lock.
+ records = sorted(self.directory.glob("*.json"))
+ overflow = len(records) - self.max_pending + 1
+ if overflow <= 0:
+ return
+ for path in records[:overflow]:
+ pending = self._load(path)
+ if pending is not None:
+ self._delete(pending)
+ else:
+ path.unlink(missing_ok=True)
+ logger.warning(
+ "wildedge: attachment buffer full (%d); dropped %d oldest",
+ self.max_pending,
+ overflow,
+ )
+
+ def peek_many(self, n: int) -> list[PendingAttachment]:
+ with self.lock:
+ result: list[PendingAttachment] = []
+ for path in sorted(self.directory.glob("*.json"))[:n]:
+ pending = self._load(path)
+ if pending is not None:
+ result.append(pending)
+ else:
+ path.unlink(missing_ok=True)
+ return result
+
+ def remove(self, pendings: list[PendingAttachment]) -> None:
+ with self.lock:
+ for pending in pendings:
+ self._delete(pending)
+
+ def _delete(self, pending: PendingAttachment) -> None:
+ # Caller holds the lock.
+ pending.record_path.unlink(missing_ok=True)
+ if pending.bin_path is not None:
+ pending.bin_path.unlink(missing_ok=True)
+
+ def clear(self) -> None:
+ with self.lock:
+ for path in self.directory.glob("*.json"):
+ pending = self._load(path)
+ if pending is not None:
+ self._delete(pending)
+ else:
+ path.unlink(missing_ok=True)
+ for stray in self.directory.glob("*.bin"):
+ stray.unlink(missing_ok=True)
+
+ def length(self) -> int:
+ with self.lock:
+ return sum(1 for _ in self.directory.glob("*.json"))
+
+ def _load(self, path: Path) -> PendingAttachment | None:
+ try:
+ record = json.loads(path.read_text())
+ except Exception:
+ return None
+ if not isinstance(record, dict):
+ return None
+ try:
+ kind = record["payload_kind"]
+ bin_path: Path | None = None
+ inline_data: bytes | None = None
+ if kind == "file":
+ bin_path = self.directory / record["bin_file"]
+ if not bin_path.exists():
+ return None
+ elif kind == "inline":
+ inline_data = base64.b64decode(record["data_b64"])
+ else:
+ return None
+ return PendingAttachment(
+ attachment_id=str(record["attachment_id"]),
+ inference_id=str(record["inference_id"]),
+ role=str(record["role"]),
+ content_type=str(record["content_type"]),
+ size_bytes=int(record["size_bytes"]),
+ inference_timestamp=str(record["inference_timestamp"]),
+ registered_at=float(record["registered_at"]),
+ record_path=path,
+ bin_path=bin_path,
+ inline_data=inline_data,
+ name=record.get("name"),
+ )
+ except (KeyError, ValueError, TypeError):
+ return None
+
+
+class AttachmentManager:
+ """Capture-side gate: applies the filter hook and per-inference caps, then
+ buffers eligible attachments and returns the event references."""
+
+ def __init__(
+ self,
+ store: AttachmentStore,
+ *,
+ max_per_inference: int = constants.DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE,
+ max_size_bytes: int = constants.DEFAULT_MAX_ATTACHMENT_SIZE_BYTES,
+ attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None,
+ debug: bool = False,
+ ) -> None:
+ self.store = store
+ self.max_per_inference = max_per_inference
+ self.max_size_bytes = max_size_bytes
+ self.attachment_filter = attachment_filter
+ self.debug = debug
+ self.enabled = True
+
+ def disable(self) -> None:
+ """Stop accepting attachments (e.g. the project has the feature off)."""
+ self.enabled = False
+
+ def capture(
+ self,
+ attachments: list[Attachment],
+ inference_id: str,
+ inference_timestamp: datetime,
+ ) -> list[dict[str, str]]:
+ if not self.enabled or not attachments:
+ return []
+
+ selected = attachments
+ if self.attachment_filter is not None:
+ try:
+ selected = self.attachment_filter(list(attachments))
+ except Exception as exc: # pragma: no cover - user callback failures
+ logger.warning("wildedge: attachment_filter raised: %s", exc)
+ return []
+
+ if len(selected) > self.max_per_inference:
+ if self.debug:
+ logger.debug(
+ "wildedge: %d attachments exceeds max_per_inference=%d; dropping excess",
+ len(selected),
+ self.max_per_inference,
+ )
+ selected = selected[: self.max_per_inference]
+
+ ts = inference_timestamp.isoformat()
+ refs: list[dict[str, str]] = []
+ for attachment in selected:
+ try:
+ data = attachment.resolve_bytes()
+ except Exception as exc:
+ logger.warning("wildedge: could not read attachment bytes: %s", exc)
+ continue
+ if len(data) > self.max_size_bytes:
+ if self.debug:
+ logger.debug(
+ "wildedge: attachment (%d bytes) exceeds max_size_bytes=%d; dropping",
+ len(data),
+ self.max_size_bytes,
+ )
+ continue
+ attachment_id = str(uuid.uuid4())
+ try:
+ self.store.append(
+ attachment=attachment,
+ attachment_id=attachment_id,
+ inference_id=inference_id,
+ inference_timestamp=ts,
+ data=data,
+ )
+ except Exception as exc:
+ logger.warning("wildedge: failed to buffer attachment: %s", exc)
+ continue
+ refs.append(
+ {
+ "attachment_id": attachment_id,
+ "role": attachment.role,
+ "content_type": attachment.content_type,
+ }
+ )
+ return refs
+
+
+class AttachmentTransmitter:
+ """Presigns and PUTs a single attachment. Presign hits the ingest host with
+ the project secret; the PUT goes straight to the returned blob-store URL
+ with no secret."""
+
+ def __init__(
+ self,
+ api_key: str,
+ host: str,
+ timeout: float = constants.DEFAULT_ATTACHMENT_HTTP_TIMEOUT,
+ ) -> None:
+ self.api_key = api_key
+ self.host = host.rstrip("/")
+ self.timeout = timeout
+
+ def transmit(self, pending: PendingAttachment) -> UploadOutcome:
+ outcome, upload_url = self._presign(pending)
+ if upload_url is None:
+ return outcome
+ try:
+ data = pending.read_bytes()
+ except Exception as exc:
+ logger.warning("wildedge: attachment bytes unreadable: %s", exc)
+ return UploadOutcome.PERMANENT
+ return self._put(upload_url, data, pending.content_type)
+
+ def _presign(self, pending: PendingAttachment) -> tuple[UploadOutcome, str | None]:
+ url = f"{self.host}/api/attachments/presign"
+ body = json.dumps(
+ [
+ {
+ "attachment_id": pending.attachment_id,
+ "role": pending.role,
+ "content_type": pending.content_type,
+ "size_bytes": pending.size_bytes,
+ "inference_timestamp": pending.inference_timestamp,
+ }
+ ]
+ ).encode()
+ req = urllib.request.Request(
+ url,
+ data=body,
+ headers={
+ "User-Agent": constants.SDK_VERSION,
+ "X-Project-Secret": self.api_key,
+ "Content-Type": "application/json",
+ },
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+ status = resp.status
+ raw = resp.read()
+ except urllib.error.HTTPError as exc:
+ status = exc.code
+ raw = exc.read()
+ except (urllib.error.URLError, OSError) as exc:
+ logger.debug("wildedge: presign network error: %s", exc)
+ return UploadOutcome.TRANSIENT, None
+
+ if status in (200, 201):
+ try:
+ entries = json.loads(raw)
+ upload_url = entries[0]["upload_url"]
+ except (ValueError, KeyError, IndexError, TypeError):
+ return UploadOutcome.TRANSIENT, None
+ return UploadOutcome.UPLOADED, upload_url
+ if status == 403:
+ logger.warning(
+ "wildedge: attachments not enabled for this project (403); "
+ "disabling attachment upload"
+ )
+ return UploadOutcome.FEATURE_DISABLED, None
+ if status == 422:
+ logger.warning(
+ "wildedge: attachment presign validation error (422): %s",
+ raw[: constants.ERROR_MSG_MAX_LEN],
+ )
+ return UploadOutcome.PERMANENT, None
+ if status == 401 or status == 400:
+ logger.warning(
+ "wildedge: attachment presign rejected (%d) - dropping", status
+ )
+ return UploadOutcome.PERMANENT, None
+ # 429 / 5xx / anything else: retry later.
+ return UploadOutcome.TRANSIENT, None
+
+ def _put(self, upload_url: str, data: bytes, content_type: str) -> UploadOutcome:
+ req = urllib.request.Request(
+ upload_url,
+ data=data,
+ headers={"Content-Type": content_type},
+ method="PUT",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+ status = resp.status
+ except urllib.error.HTTPError as exc:
+ status = exc.code
+ except (urllib.error.URLError, OSError) as exc:
+ logger.debug("wildedge: attachment upload network error: %s", exc)
+ return UploadOutcome.TRANSIENT
+
+ if status in (200, 201, 202, 204):
+ return UploadOutcome.UPLOADED
+ if status == 400:
+ return UploadOutcome.PERMANENT
+ # Expired/invalid signature (403) and 5xx: retry, which re-presigns.
+ return UploadOutcome.TRANSIENT
+
+
+class AttachmentUploader:
+ """Background daemon thread that drains the AttachmentStore oldest-first."""
+
+ def __init__(
+ self,
+ store: AttachmentStore,
+ transmitter: AttachmentTransmitter,
+ *,
+ flush_interval_s: float = constants.DEFAULT_ATTACHMENT_FLUSH_INTERVAL_SEC,
+ max_age_s: float = constants.DEFAULT_MAX_ATTACHMENT_AGE_SEC,
+ on_feature_disabled: Callable[[], None] | None = None,
+ debug: bool = False,
+ ) -> None:
+ self.store = store
+ self.transmitter = transmitter
+ self.flush_interval_s = flush_interval_s
+ self.max_age_s = max_age_s
+ self.on_feature_disabled = on_feature_disabled
+ self.debug = debug
+ self.uploaded_count = 0
+ self.dropped_count = 0
+ self.stop_event = threading.Event()
+ self.thread = self._make_thread()
+
+ def _make_thread(self) -> threading.Thread:
+ return threading.Thread(
+ target=self._run, daemon=True, name="wildedge-attachment-uploader"
+ )
+
+ def start(self) -> None:
+ self.thread.start()
+
+ def stop(self) -> None:
+ self.stop_event.set()
+ if self.thread.is_alive():
+ self.thread.join(timeout=2.0)
+
+ def _pause(self) -> None:
+ """Stop the thread before a fork() so no wildedge threads survive."""
+ self.stop_event.set()
+ if self.thread.is_alive():
+ self.thread.join(timeout=1.0)
+
+ def _resume(self) -> None:
+ """Start a fresh thread after a fork() in parent and child."""
+ self.stop_event = threading.Event()
+ self.thread = self._make_thread()
+ self.thread.start()
+
+ def _run(self) -> None:
+ while not self.stop_event.wait(self.flush_interval_s):
+ try:
+ self.process_pending()
+ except Exception as exc: # pragma: no cover - never kill the thread
+ logger.warning("wildedge: attachment uploader error: %s", exc)
+
+ def process_pending(self) -> None:
+ # Evict stale buffered bytes from the front of the queue.
+ cutoff = time.time() - self.max_age_s
+ batch = self.store.peek_many(constants.ATTACHMENT_PRESIGN_BATCH_SIZE)
+ stale = [p for p in batch if p.registered_at < cutoff]
+ if stale:
+ self.store.remove(stale)
+ self.dropped_count += len(stale)
+ logger.warning(
+ "wildedge: evicted %d stale attachment(s) (age > %.0fs)",
+ len(stale),
+ self.max_age_s,
+ )
+ batch = [p for p in batch if p.registered_at >= cutoff]
+
+ done: list[PendingAttachment] = []
+ for pending in batch:
+ outcome = self.transmitter.transmit(pending)
+ if outcome is UploadOutcome.UPLOADED:
+ done.append(pending)
+ self.uploaded_count += 1
+ elif outcome is UploadOutcome.PERMANENT:
+ done.append(pending)
+ self.dropped_count += 1
+ elif outcome is UploadOutcome.FEATURE_DISABLED:
+ if self.on_feature_disabled is not None:
+ self.on_feature_disabled()
+ self.store.remove(done)
+ self.store.clear()
+ self.stop_event.set()
+ return
+ else: # TRANSIENT - stop, preserve order, retry next tick
+ break
+
+ if done:
+ self.store.remove(done)
diff --git a/wildedge/cli.py b/wildedge/cli.py
index 3d44b75..967b23b 100644
--- a/wildedge/cli.py
+++ b/wildedge/cli.py
@@ -58,6 +58,11 @@ def build_parser() -> argparse.ArgumentParser:
default=constants.DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SEC,
help="Flush timeout (seconds) for shutdown.",
)
+ run.add_argument(
+ "--attachments",
+ action="store_true",
+ help="Enable opt-in raw input/output attachment upload (paid feature).",
+ )
run.add_argument(
"--debug",
action="store_true",
@@ -242,6 +247,8 @@ def run_command(parsed: argparse.Namespace) -> int:
env[constants.ENV_HUBS] = parsed.hubs
env[constants.ENV_SAMPLING_INTERVAL] = str(parsed.sampling_interval)
env[constants.ENV_FLUSH_TIMEOUT] = str(parsed.flush_timeout)
+ if parsed.attachments:
+ env[constants.ENV_ATTACHMENTS_ENABLED] = "1"
env[constants.ENV_PROPAGATE] = "1" if parsed.propagate else "0"
env[constants.ENV_STRICT_INTEGRATIONS] = "1" if parsed.strict_integrations else "0"
env[constants.ENV_PRINT_STARTUP_REPORT] = (
diff --git a/wildedge/client.py b/wildedge/client.py
index a6ac911..48fbfe2 100644
--- a/wildedge/client.py
+++ b/wildedge/client.py
@@ -10,6 +10,13 @@
from urllib.parse import urlparse
from wildedge import constants
+from wildedge.attachments import (
+ Attachment,
+ AttachmentManager,
+ AttachmentStore,
+ AttachmentTransmitter,
+ AttachmentUploader,
+)
from wildedge.consumer import Consumer
from wildedge.dead_letters import DeadLetterStore
from wildedge.events import SpanEvent
@@ -33,6 +40,7 @@
from wildedge.logging import enable_debug, logger
from wildedge.model import ModelHandle, ModelInfo, ModelRegistry
from wildedge.paths import (
+ default_attachment_dir,
default_dead_letter_dir,
default_model_registry_path,
default_pending_queue_dir,
@@ -265,6 +273,16 @@ def __init__(
max_dead_letter_batches: int = constants.DEFAULT_MAX_DEAD_LETTER_BATCHES,
on_delivery_failure: Callable[[str, int, int], None] | None = None,
sampling_interval_s: float | None = constants.DEFAULT_SAMPLING_INTERVAL_S,
+ attachments_enabled: bool | None = None,
+ max_attachments_per_inference: int = (
+ constants.DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE
+ ),
+ max_attachment_size_bytes: int = constants.DEFAULT_MAX_ATTACHMENT_SIZE_BYTES,
+ attachment_storage_strategy: str = (
+ constants.DEFAULT_ATTACHMENT_STORAGE_STRATEGY
+ ),
+ attachment_filter: Callable[[list[Attachment]], list[Attachment]] | None = None,
+ attachment_dir: str | None = None,
):
env = read_client_env(dsn=dsn, debug=debug, app_identity=app_identity)
dsn = env.dsn
@@ -356,6 +374,37 @@ def __init__(
if sampling_interval_s:
start_sampler(interval_s=sampling_interval_s)
+ if attachments_enabled is None:
+ attachments_enabled = (
+ env.attachments_enabled
+ if env.attachments_enabled is not None
+ else constants.DEFAULT_ATTACHMENTS_ENABLED
+ )
+ self.attachment_manager: AttachmentManager | None = None
+ self.attachment_uploader: AttachmentUploader | None = None
+ if attachments_enabled:
+ resolved_attachment_dir = attachment_dir or str(
+ default_attachment_dir(app_identity)
+ )
+ attachment_store = AttachmentStore(
+ directory=resolved_attachment_dir,
+ strategy=attachment_storage_strategy,
+ )
+ self.attachment_manager = AttachmentManager(
+ attachment_store,
+ max_per_inference=max_attachments_per_inference,
+ max_size_bytes=max_attachment_size_bytes,
+ attachment_filter=attachment_filter,
+ debug=debug,
+ )
+ self.attachment_uploader = AttachmentUploader(
+ attachment_store,
+ AttachmentTransmitter(api_key=api_key, host=host),
+ on_feature_disabled=self.attachment_manager.disable,
+ debug=debug,
+ )
+ self.attachment_uploader.start()
+
self.auto_loaded: set[str] = set()
# Stores the active weakref.finalize for each auto-loaded model so that
# when a wrapping object (e.g. a Pipeline) supersedes the inner model, the
@@ -371,6 +420,12 @@ def __init__(
after_in_child=self.consumer._resume,
after_in_parent=self.consumer._resume,
)
+ if self.attachment_uploader is not None:
+ os.register_at_fork(
+ before=self.attachment_uploader._pause,
+ after_in_child=self.attachment_uploader._resume,
+ after_in_parent=self.attachment_uploader._resume,
+ )
if debug:
logger.debug("wildedge: client initialized (session=%s)", self.session_id)
@@ -398,6 +453,8 @@ def _init_noop(self, *, debug: bool, device: DeviceInfo | None) -> None:
self.auto_loaded = set()
self._auto_load_finalizers = {}
self.hub_trackers = {}
+ self.attachment_manager = None
+ self.attachment_uploader = None
def publish(self, event_dict: dict) -> None:
if self.closed or self.noop:
@@ -467,7 +524,14 @@ def register_model(
"Pass id='your-model-id' to register_model()."
)
- handle, is_new = self.registry.register(model_id, info, self.publish)
+ capture_attachments = (
+ self.attachment_manager.capture
+ if self.attachment_manager is not None
+ else None
+ )
+ handle, is_new = self.registry.register(
+ model_id, info, self.publish, capture_attachments
+ )
if not is_new:
logger.debug(
@@ -909,6 +973,8 @@ def close(self, timeout: float | None = None) -> None:
self.closed = True
if not self.noop:
stop_sampler()
+ if self.attachment_uploader is not None:
+ self.attachment_uploader.stop()
if timeout is None:
self.consumer.close()
else:
diff --git a/wildedge/constants.py b/wildedge/constants.py
index 8c3ebd0..87b5afd 100644
--- a/wildedge/constants.py
+++ b/wildedge/constants.py
@@ -1,5 +1,10 @@
# SDK identity
-SDK_VERSION = "wildedge-python-0.1.0"
+from importlib.metadata import PackageNotFoundError, version
+
+try:
+ SDK_VERSION = f"wildedge-python-{version('wildedge-sdk')}"
+except PackageNotFoundError: # running from a source tree without install metadata
+ SDK_VERSION = "wildedge-python-0.0.0+unknown"
PROTOCOL_VERSION = "1.0"
# Env vars
@@ -14,6 +19,7 @@
ENV_PROPAGATE = "WILDEDGE_PROPAGATE"
ENV_PRINT_STARTUP_REPORT = "WILDEDGE_PRINT_STARTUP_REPORT"
ENV_SAMPLING_INTERVAL = "WILDEDGE_SAMPLING_INTERVAL_S"
+ENV_ATTACHMENTS_ENABLED = "WILDEDGE_ATTACHMENTS_ENABLED"
# Defaults
DEFAULT_SAMPLING_INTERVAL_S = 30.0
@@ -27,6 +33,17 @@
DEFAULT_MAX_DEAD_LETTER_BATCHES = 10
DEFAULT_SHUTDOWN_FLUSH_TIMEOUT_SEC = 0.0
+# Attachments (opt-in raw input/output upload)
+DEFAULT_ATTACHMENTS_ENABLED = False
+DEFAULT_ATTACHMENT_STORAGE_STRATEGY = "file" # "file" | "inline"
+DEFAULT_MAX_ATTACHMENTS_PER_INFERENCE = 10
+DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
+DEFAULT_MAX_PENDING_ATTACHMENTS = 100
+DEFAULT_MAX_ATTACHMENT_AGE_SEC = 5 * 60
+DEFAULT_ATTACHMENT_FLUSH_INTERVAL_SEC = 5.0
+DEFAULT_ATTACHMENT_HTTP_TIMEOUT = 10.0
+ATTACHMENT_PRESIGN_BATCH_SIZE = 5
+
# Consumer backoff
BACKOFF_MIN = 1.0
BACKOFF_MAX = 60.0
diff --git a/wildedge/events/inference.py b/wildedge/events/inference.py
index ef76ae5..ecbb1b4 100644
--- a/wildedge/events/inference.py
+++ b/wildedge/events/inference.py
@@ -106,7 +106,6 @@ class TextInputMeta:
contains_code: bool | None = None
prompt_type: str | None = None
turn_index: int | None = None
- has_attachments: bool | None = None
def to_dict(self) -> dict:
return {
@@ -121,7 +120,6 @@ def to_dict(self) -> dict:
"contains_code": self.contains_code,
"prompt_type": self.prompt_type,
"turn_index": self.turn_index,
- "has_attachments": self.has_attachments,
}.items()
if v is not None
}
@@ -304,6 +302,7 @@ class InferenceEvent:
generation_config: GenerationConfig | None = None
hardware: HardwareContext | None = None
api_meta: ApiMeta | None = None
+ attachments: list[dict[str, Any]] | None = None
trace_id: str | None = None
span_id: str | None = None
parent_span_id: str | None = None
@@ -348,6 +347,8 @@ def to_dict(self) -> dict:
"model_id": self.model_id,
"inference": inference_data,
}
+ if self.attachments:
+ event["attachments"] = self.attachments
from wildedge.events.common import add_optional_fields
add_optional_fields(
diff --git a/wildedge/model.py b/wildedge/model.py
index 2502b14..e133172 100644
--- a/wildedge/model.py
+++ b/wildedge/model.py
@@ -3,9 +3,11 @@
import json
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import datetime
from pathlib import Path
from typing import Any
+from wildedge.attachments import Attachment
from wildedge.events import (
ApiMeta,
AudioInputMeta,
@@ -54,10 +56,18 @@ def to_dict(self) -> dict:
class ModelHandle:
"""Handle returned by register_model(). All events for a model go through here."""
- def __init__(self, model_id: str, info: ModelInfo, publish: Callable[[dict], None]):
+ def __init__(
+ self,
+ model_id: str,
+ info: ModelInfo,
+ publish: Callable[[dict], None],
+ capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]]
+ | None = None,
+ ):
self.model_id = model_id
self.info = info
self.publish = publish
+ self.capture_attachments = capture_attachments
self.detected_accelerator: str = "cpu"
self.last_inference_id: str | None = None
@@ -204,6 +214,7 @@ def track_inference(
generation_config: GenerationConfig | None = None,
hardware: HardwareContext | None = None,
api_meta: ApiMeta | None = None,
+ attachments: list[Attachment] | None = None,
trace_id: str | None = None,
span_id: str | None = None,
parent_span_id: str | None = None,
@@ -214,7 +225,7 @@ def track_inference(
context: dict[str, Any] | None = None,
) -> str:
if hardware is None and is_sampling():
- hardware = capture_hardware()
+ hardware = capture_hardware(accelerator_actual=self.detected_accelerator)
correlation = _merge_correlation_fields(
trace_id=trace_id,
span_id=span_id,
@@ -240,6 +251,12 @@ def track_inference(
api_meta=api_meta,
**correlation,
)
+ if attachments and self.capture_attachments is not None:
+ refs = self.capture_attachments(
+ attachments, event.inference_id, event.timestamp
+ )
+ if refs:
+ event.attachments = refs
self.last_inference_id = event.inference_id
self.publish(event.to_dict())
return event.inference_id
@@ -375,7 +392,12 @@ def save_to_disk(self) -> None:
)
def register(
- self, model_id: str, info: ModelInfo, publish: Callable[[dict], None]
+ self,
+ model_id: str,
+ info: ModelInfo,
+ publish: Callable[[dict], None],
+ capture_attachments: Callable[[list[Attachment], str, datetime], list[dict]]
+ | None = None,
) -> tuple[ModelHandle, bool]:
"""Return (handle, is_new). is_new=False means already registered; skip install_hooks."""
if model_id in self.handles:
@@ -386,7 +408,10 @@ def register(
self.save_to_disk()
handle = ModelHandle(
- model_id=model_id, info=self.models[model_id], publish=publish
+ model_id=model_id,
+ info=self.models[model_id],
+ publish=publish,
+ capture_attachments=capture_attachments,
)
self.handles[model_id] = handle
return handle, True
diff --git a/wildedge/paths.py b/wildedge/paths.py
index de272e8..8226311 100644
--- a/wildedge/paths.py
+++ b/wildedge/paths.py
@@ -32,3 +32,7 @@ def default_model_registry_path(namespace: str = "default") -> Path:
def default_dead_letter_dir(namespace: str = "default") -> Path:
return default_sdk_cache_dir() / normalize_namespace(namespace) / "dead_letters"
+
+
+def default_attachment_dir(namespace: str = "default") -> Path:
+ return default_sdk_state_dir() / normalize_namespace(namespace) / "attachments"
diff --git a/wildedge/platforms/macos.py b/wildedge/platforms/macos.py
index bcbb71b..dc86852 100644
--- a/wildedge/platforms/macos.py
+++ b/wildedge/platforms/macos.py
@@ -6,6 +6,7 @@
from pathlib import Path
from wildedge.platforms.base import Platform, debug_detection_failure
+from wildedge.platforms.hardware import ThermalContext
_libc: ctypes.CDLL | None = None
@@ -17,8 +18,16 @@ def _get_libc() -> ctypes.CDLL:
return _libc
-_CF_STRING_ENCODING_UTF8 = 0x08000100
-_CF_NUMBER_SINT32_TYPE = 3
+CF_STRING_ENCODING_UTF8 = 0x08000100
+CF_NUMBER_SINT32_TYPE = 3
+
+# NSProcessInfoThermalState values (0–3)
+MACOS_THERMAL_STATES: dict[int, tuple[str, str]] = {
+ 0: ("nominal", "nominal"),
+ 1: ("fair", "fair"),
+ 2: ("serious", "serious"),
+ 3: ("critical", "critical"),
+}
def _sysctl_uint32(name: bytes) -> int | None:
@@ -108,6 +117,52 @@ def gpu_accelerators(self) -> tuple[list[str], str | None]:
def gpu_accelerator_for_offload(self) -> str:
return "mps" if platform.machine() == "arm64" else "cpu"
+ def cpu_freq(self) -> tuple[int | None, int | None]:
+ """Read CPU frequency via sysctl.
+
+ Current freq (hw.cpufrequency) is Intel-only — Apple Silicon does not expose
+ per-core clock speed through public sysctl, so cur_mhz is None on M-series.
+
+ Max freq falls back to hw.perflevel0.cpufrequency_max (P-core cluster) when
+ hw.cpufrequency_max is absent, so max_mhz is populated on both architectures.
+ """
+ cur = _sysctl_uint64(b"hw.cpufrequency")
+ cur_mhz = (cur // 1_000_000) if cur else None
+
+ max_f = _sysctl_uint64(b"hw.cpufrequency_max") or _sysctl_uint64(
+ b"hw.perflevel0.cpufrequency_max"
+ )
+ max_mhz = (max_f // 1_000_000) if max_f else None
+
+ return cur_mhz, max_mhz
+
+ def thermal(self) -> ThermalContext | None:
+ """Read thermal pressure via NSProcessInfo.thermalState (Objective-C runtime)."""
+ try:
+ objc = ctypes.cdll.LoadLibrary("/usr/lib/libobjc.A.dylib")
+ objc.objc_getClass.restype = ctypes.c_void_p
+ objc.sel_registerName.restype = ctypes.c_void_p
+ objc.objc_msgSend.restype = ctypes.c_void_p
+ objc.objc_msgSend.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
+
+ ns_process_info_cls = objc.objc_getClass(b"NSProcessInfo")
+ info = objc.objc_msgSend(
+ ns_process_info_cls, objc.sel_registerName(b"processInfo")
+ )
+
+ # thermalState returns NSInteger — cast objc_msgSend to the right signature
+ addr = ctypes.cast(objc.objc_msgSend, ctypes.c_void_p).value
+ msg_int = ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p)(
+ addr
+ )
+ level = int(msg_int(info, objc.sel_registerName(b"thermalState")))
+
+ state, state_raw = MACOS_THERMAL_STATES.get(level, ("nominal", "nominal"))
+ return ThermalContext(state=state, state_raw=state_raw)
+ except Exception as exc:
+ debug_detection_failure("macos thermal", exc)
+ return None
+
def battery(self) -> tuple[float | None, bool | None]:
"""Read battery level and charging state via IOKit AppleSmartBattery."""
try:
@@ -163,7 +218,7 @@ def battery(self) -> tuple[float | None, bool | None]:
def cfstr(s: bytes) -> ctypes.c_void_p:
return ctypes.c_void_p(
- cf.CFStringCreateWithCString(None, s, _CF_STRING_ENCODING_UTF8)
+ cf.CFStringCreateWithCString(None, s, CF_STRING_ENCODING_UTF8)
)
cap_key = cfstr(b"CurrentCapacity")
@@ -179,10 +234,10 @@ def cfstr(s: bytes) -> ctypes.c_void_p:
cur = ctypes.c_int32(0)
mx = ctypes.c_int32(0)
cf.CFNumberGetValue(
- ctypes.c_void_p(cap_ref), _CF_NUMBER_SINT32_TYPE, ctypes.byref(cur)
+ ctypes.c_void_p(cap_ref), CF_NUMBER_SINT32_TYPE, ctypes.byref(cur)
)
cf.CFNumberGetValue(
- ctypes.c_void_p(max_ref), _CF_NUMBER_SINT32_TYPE, ctypes.byref(mx)
+ ctypes.c_void_p(max_ref), CF_NUMBER_SINT32_TYPE, ctypes.byref(mx)
)
if mx.value > 0:
level = cur.value / mx.value
diff --git a/wildedge/settings.py b/wildedge/settings.py
index a01b88c..138832d 100644
--- a/wildedge/settings.py
+++ b/wildedge/settings.py
@@ -14,6 +14,7 @@ class ClientEnv:
dsn: str | None
debug: bool
app_identity: str | None
+ attachments_enabled: bool | None
@dataclass(frozen=True)
@@ -79,10 +80,13 @@ def read_client_env(
debug if debug is not None else parse_bool(env.get(constants.ENV_DEBUG))
)
resolved_identity = app_identity or env.get(constants.ENV_APP_IDENTITY)
+ raw_attachments = env.get(constants.ENV_ATTACHMENTS_ENABLED)
+ attachments_enabled = parse_bool(raw_attachments) if raw_attachments else None
return ClientEnv(
dsn=resolved_dsn,
debug=resolved_debug,
app_identity=resolved_identity,
+ attachments_enabled=attachments_enabled,
)