diff --git a/README.md b/README.md index f5a7e14..7c10bae 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ client.instrument("transformers", hubs=["huggingface"]) # models loaded after this point are tracked automatically ``` +If no DSN is configured, the client becomes a no-op and logs a warning. + ## Supported integrations **On-device** @@ -90,7 +92,7 @@ For unsupported frameworks, see [Manual tracking](https://github.com/wild-edge/w | Parameter | Default | Description | |---|---|---| -| `dsn` | - | `https://@ingest.wildedge.dev/` (or `WILDEDGE_DSN`) | +| `dsn` | - | `https://@ingest.wildedge.dev/` (or `WILDEDGE_DSN`). If unset, the client is a no-op. | | `app_version` | `None` | Your app's version string | | `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 | diff --git a/docs/configuration.md b/docs/configuration.md index 6244fc8..566738b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,7 +6,7 @@ Full reference for all `WildEdge` client parameters. | Parameter | Default | Env var | Description | |---|---|---|---| -| `dsn` | - | `WILDEDGE_DSN` | `https://@ingest.wildedge.dev/` | +| `dsn` | - | `WILDEDGE_DSN` | `https://@ingest.wildedge.dev/`. If unset, the client is a no-op. | | `app_version` | `None` | - | Your app's version string | | `app_identity` | `` | `WILDEDGE_APP_IDENTITY` | Namespace for offline persistence. Set per-app in multi-process workloads | | `enable_offline_persistence` | `true` | - | Persist unsent events to disk and replay on restart | diff --git a/docs/manual-tracking.md b/docs/manual-tracking.md index 20d2fe5..e3b1615 100644 --- a/docs/manual-tracking.md +++ b/docs/manual-tracking.md @@ -25,7 +25,7 @@ Every model needs a handle before you can track events against it. Pass the mode ```python import wildedge -client = wildedge.WildEdge() # set WILDEDGE_DSN env var +client = wildedge.WildEdge() # uses WILDEDGE_DSN if set; otherwise no-op handle = client.register_model( my_model, diff --git a/examples/feedback_example.py b/examples/feedback_example.py index 11f9e7c..4644e9e 100644 --- a/examples/feedback_example.py +++ b/examples/feedback_example.py @@ -22,7 +22,7 @@ CONFIDENCE_THRESHOLD = 0.6 client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) client.instrument("timm") diff --git a/examples/gguf_example.py b/examples/gguf_example.py index 074d535..5af3f25 100644 --- a/examples/gguf_example.py +++ b/examples/gguf_example.py @@ -13,7 +13,7 @@ import wildedge client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) client.instrument("gguf", hubs=["huggingface"]) diff --git a/examples/gguf_gemma_example.py b/examples/gguf_gemma_example.py index f3962cf..94afd36 100644 --- a/examples/gguf_gemma_example.py +++ b/examples/gguf_gemma_example.py @@ -28,7 +28,7 @@ FILE = "gemma-2-2b-it-Q4_K_M.gguf" client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) # --- Download --- diff --git a/examples/keras_example.py b/examples/keras_example.py index b782139..0fc2e1c 100644 --- a/examples/keras_example.py +++ b/examples/keras_example.py @@ -28,7 +28,7 @@ exit(1) client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) # Load a pre-trained MobileNetV2 model using client to track construction and lifecycle diff --git a/examples/mlx_example.py b/examples/mlx_example.py index 2c87a3a..1e70b82 100644 --- a/examples/mlx_example.py +++ b/examples/mlx_example.py @@ -52,7 +52,9 @@ def main() -> None: # instrument() patches mlx_lm.load and mlx_lm.generate; must be called # before any model is loaded. - client = wildedge.WildEdge(app_version="1.0.0") # set WILDEDGE_DSN env var + client = wildedge.WildEdge( + app_version="1.0.0" + ) # uses WILDEDGE_DSN if set; otherwise no-op client.instrument("mlx", hubs=["huggingface"]) print(f"\nLoading {args.model} ...") diff --git a/examples/onnx_example.py b/examples/onnx_example.py index a5a9232..e9c4318 100644 --- a/examples/onnx_example.py +++ b/examples/onnx_example.py @@ -14,7 +14,7 @@ import wildedge client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) client.instrument("onnx", hubs=["huggingface"]) diff --git a/examples/openai_example.py b/examples/openai_example.py index e2e2e87..4515d4e 100644 --- a/examples/openai_example.py +++ b/examples/openai_example.py @@ -18,7 +18,9 @@ import wildedge -client = wildedge.WildEdge(app_version="1.0.0") # set WILDEDGE_DSN env var +client = wildedge.WildEdge( + app_version="1.0.0" +) # uses WILDEDGE_DSN if set; otherwise no-op client.instrument("openai") openai_client = OpenAI() # set OPENAI_API_KEY env var or pass api_key= explicitly diff --git a/examples/pytorch_example.py b/examples/pytorch_example.py index c384b15..8868455 100644 --- a/examples/pytorch_example.py +++ b/examples/pytorch_example.py @@ -13,7 +13,7 @@ import wildedge client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) diff --git a/examples/tensorflow_example.py b/examples/tensorflow_example.py index 41641e2..cd6d1b0 100644 --- a/examples/tensorflow_example.py +++ b/examples/tensorflow_example.py @@ -18,7 +18,7 @@ import wildedge client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) client.instrument("tensorflow") diff --git a/examples/timm_example.py b/examples/timm_example.py index 1a302e7..ffea6c3 100644 --- a/examples/timm_example.py +++ b/examples/timm_example.py @@ -20,7 +20,7 @@ import wildedge client = wildedge.WildEdge( - app_version="1.0.0", # set WILDEDGE_DSN env var + app_version="1.0.0", # uses WILDEDGE_DSN if set; otherwise no-op ) client.instrument("timm", hubs=["huggingface", "torchhub"]) diff --git a/examples/transformers_example.py b/examples/transformers_example.py index bac9df4..cff9277 100644 --- a/examples/transformers_example.py +++ b/examples/transformers_example.py @@ -92,7 +92,9 @@ def main() -> None: ) args = parser.parse_args() - client = wildedge.WildEdge(app_version="1.0.0") # set WILDEDGE_DSN env var + client = wildedge.WildEdge( + app_version="1.0.0" + ) # uses WILDEDGE_DSN if set; otherwise no-op client.instrument("transformers", hubs=["huggingface"]) print() diff --git a/tests/test_client.py b/tests/test_client.py index 971c64a..3417ba8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -31,6 +31,38 @@ def test_batch_size_too_low(): WildEdge(dsn="https://test@test.com/key", batch_size=0) +def test_no_dsn_is_noop(monkeypatch, caplog): + from wildedge.client import WildEdge + + monkeypatch.delenv(constants.ENV_DSN, raising=False) + with caplog.at_level("WARNING"): + client = WildEdge() + + assert client.noop is True + assert client.closed is True + assert "no DSN configured" in caplog.text + + +def test_no_dsn_instrument_does_not_raise(monkeypatch): + from wildedge.client import WildEdge + + monkeypatch.delenv(constants.ENV_DSN, raising=False) + client = WildEdge() + + # No-DSN client should ignore instrument() entirely (even for unknown integration). + client.instrument("definitely-not-real") + + +def test_no_dsn_publish_does_not_enqueue(monkeypatch): + from wildedge.client import WildEdge + + monkeypatch.delenv(constants.ENV_DSN, raising=False) + client = WildEdge() + + client.publish({"event_type": "inference", "model_id": "m"}) + client.queue.add.assert_not_called() + + def test_batch_size_too_high(): from wildedge.client import WildEdge diff --git a/wildedge/client.py b/wildedge/client.py index da08f02..da5e052 100644 --- a/wildedge/client.py +++ b/wildedge/client.py @@ -68,6 +68,14 @@ ) +class _NoopConsumer: + def flush(self, timeout: float = 0) -> None: + return None + + def close(self, timeout: float | None = None) -> None: + return None + + def parse_dsn(dsn: str) -> tuple[str, str, str]: """Parse DSN into (secret, host, project_key).""" parsed = urlparse(dsn) @@ -153,10 +161,12 @@ def __init__( ): env = read_client_env(dsn=dsn, debug=debug, app_identity=app_identity) dsn = env.dsn + debug = env.debug + self.noop = False if not dsn: - raise ValueError(ERROR_DSN_REQUIRED) + self._init_noop(debug=debug, device=device) + return api_key, host, project_key = parse_dsn(dsn) - debug = env.debug app_identity = resolve_app_identity( explicit=env.app_identity, project_key=project_key, @@ -258,8 +268,32 @@ def __init__( if debug: logger.debug("wildedge: client initialized (session=%s)", self.session_id) + def _init_noop(self, *, debug: bool, device: DeviceInfo | None) -> None: + self.noop = True + self.debug = debug + self.closed = True + logger.warning( + "wildedge: no DSN configured; client is disabled (events will be dropped)" + ) + self.api_key = None + self.device = device + self.session_id = str(uuid.uuid4()) + self.created_at = datetime.now(timezone.utc) + self.queue = EventQueue( + max_size=1, + policy=QueuePolicy.OPPORTUNISTIC, + persist_to_disk=False, + ) + self.registry = ModelRegistry(persist_path=None) + self.transmitter = None + self.dead_letter_store = None + self.consumer = _NoopConsumer() + self.auto_loaded = set() + self._auto_load_finalizers = {} + self.hub_trackers = {} + def publish(self, event_dict: dict) -> None: - if self.closed: + if self.closed or self.noop: return if self.debug: @@ -472,6 +506,10 @@ def instrument( Each integration or hub tracker is installed at most once per process regardless of how many times ``instrument()`` is called. """ + if self.noop: + if self.debug: + logger.debug("wildedge: instrument skipped (no DSN configured)") + return if integration is None: if not hubs: raise ValueError( @@ -658,7 +696,8 @@ def flush(self, timeout: float = 5.0) -> None: def close(self, timeout: float | None = None) -> None: """Best-effort shutdown; pass timeout to attempt bounded flush first.""" self.closed = True - stop_sampler() + if not self.noop: + stop_sampler() if timeout is None: self.consumer.close() else: