diff --git a/pyproject.toml b/pyproject.toml index 5599772c6..ee1e0eaa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "onnx-ir>=0.1", "onnxruntime-windowsml>=1.23.2,<1.24.0", "onnxscript>=0.2", + "opentelemetry-sdk>=1.39.1", "optimum>=2", "optimum-onnx>=0.1", "plotext>=5.3.2", diff --git a/src/winml/modelkit/telemetry/__init__.py b/src/winml/modelkit/telemetry/__init__.py new file mode 100644 index 000000000..862c45ce3 --- /dev/null +++ b/src/winml/modelkit/telemetry/__init__.py @@ -0,0 +1,4 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- diff --git a/src/winml/modelkit/telemetry/deviceid/__init__.py b/src/winml/modelkit/telemetry/deviceid/__init__.py new file mode 100644 index 000000000..0b6c55f41 --- /dev/null +++ b/src/winml/modelkit/telemetry/deviceid/__init__.py @@ -0,0 +1,11 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +"""Device ID: a stable SHA256-hashed UUID4 persisted per user.""" + +from .deviceid import IdStatus, get_or_create_device_id + + +__all__ = ["IdStatus", "get_or_create_device_id"] diff --git a/src/winml/modelkit/telemetry/deviceid/_store.py b/src/winml/modelkit/telemetry/deviceid/_store.py new file mode 100644 index 000000000..c75e4d2c5 --- /dev/null +++ b/src/winml/modelkit/telemetry/deviceid/_store.py @@ -0,0 +1,52 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +r"""Windows-registry-backed storage for telemetry's small key-value entries. + +Location: ``HKCU\SOFTWARE\Microsoft\DeveloperTools\.modelkit``. Values are +``REG_SZ`` strings; non-string registry types are treated as absent on +read. +""" + +from __future__ import annotations + + +_REGISTRY_KEY = r"SOFTWARE\Microsoft\DeveloperTools\.modelkit" + + +def read_key(name: str) -> str | None: + """Return the stored ``REG_SZ`` value, or ``None`` if absent or non-string.""" + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _REGISTRY_KEY) as key: + value, value_type = winreg.QueryValueEx(key, name) + except FileNotFoundError: + return None + if value_type != winreg.REG_SZ: + return None + return value # already str for REG_SZ + + +def write_key(name: str, value: str) -> None: + """Persist ``value`` as a ``REG_SZ`` under ``name``.""" + import winreg + + with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, _REGISTRY_KEY) as key: + winreg.SetValueEx(key, name, 0, winreg.REG_SZ, value) + + +def delete_key(name: str) -> None: + """Remove the stored value for ``name``. Idempotent.""" + import winreg + + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, _REGISTRY_KEY, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.DeleteValue(key, name) + except FileNotFoundError: + # Value or key already absent — delete is idempotent. + return diff --git a/src/winml/modelkit/telemetry/deviceid/deviceid.py b/src/winml/modelkit/telemetry/deviceid/deviceid.py new file mode 100644 index 000000000..e4deafd82 --- /dev/null +++ b/src/winml/modelkit/telemetry/deviceid/deviceid.py @@ -0,0 +1,66 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +"""Stable-per-device ID used as the telemetry device_id. + +Derived from a random UUID4, hashed with SHA256, and persisted so the same +machine reports the same id across sessions. Users can reset by removing the +stored value (registry on Windows, state file elsewhere). +""" + +from __future__ import annotations + +import hashlib +import logging +import uuid +from enum import Enum + +from . import _store + + +_LOGGER = logging.getLogger(__name__) +_STORAGE_KEY = "deviceid" + + +class IdStatus(str, Enum): + """Outcome of :func:`get_or_create_device_id`. + + Subclassing ``str`` keeps the enum serialization-compatible with + OpenTelemetry resource attributes (which require str values) and + with the CS 4.0 ``ext.device.authId`` slot on the wire. + """ + + EXISTING = "EXISTING" + NEW = "NEW" + FAILED = "FAILED" + + +def get_or_create_device_id() -> tuple[str, IdStatus]: + """Return ``(device_id, status)``. + + - :attr:`IdStatus.EXISTING`: read from persistent storage + - :attr:`IdStatus.NEW`: freshly generated and persisted + - :attr:`IdStatus.FAILED`: storage unavailable; caller should proceed with empty id + """ + try: + existing = _store.read_key(_STORAGE_KEY) + except Exception: # defensive: any storage error means we treat as fresh + _LOGGER.debug("deviceid read failed", exc_info=True) + existing = None + + if existing: + return existing, IdStatus.EXISTING + + new_id = _hash_uuid(uuid.uuid4()) + try: + _store.write_key(_STORAGE_KEY, new_id) + except Exception: + _LOGGER.debug("deviceid write failed", exc_info=True) + return "", IdStatus.FAILED + return new_id, IdStatus.NEW + + +def _hash_uuid(value: uuid.UUID) -> str: + return hashlib.sha256(str(value).encode("utf-8")).hexdigest() diff --git a/src/winml/modelkit/telemetry/library/__init__.py b/src/winml/modelkit/telemetry/library/__init__.py new file mode 100644 index 000000000..45748fb3a --- /dev/null +++ b/src/winml/modelkit/telemetry/library/__init__.py @@ -0,0 +1,40 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +"""Public surface for the OneCollector client. + +Consumers (the rest of the telemetry module) only import from this file. +Everything else in `library/` is private implementation detail. +""" + +from __future__ import annotations + +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.resources import Resource + +from .exporter import OneCollectorLogExporter + + +__all__ = ["DEFAULT_ENDPOINT", "OneCollectorLogExporter", "create_logger_provider"] + +DEFAULT_ENDPOINT = "https://mobile.events.data.microsoft.com/OneCollector/1.0/" + + +def create_logger_provider( + ikey: str, + endpoint: str = DEFAULT_ENDPOINT, + resource: Resource | None = None, +) -> LoggerProvider: + """Create a LoggerProvider wired to a OneCollectorLogExporter. + + The returned provider's `get_logger(name)` produces loggers whose + `.emit(LogRecord(...))` calls are batched and sent to the OneCollector + endpoint. Shutdown the provider to flush pending events. + """ + provider = LoggerProvider(resource=resource or Resource.get_empty()) + exporter = OneCollectorLogExporter(ikey=ikey, endpoint=endpoint) + provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) + return provider diff --git a/src/winml/modelkit/telemetry/library/exporter.py b/src/winml/modelkit/telemetry/library/exporter.py new file mode 100644 index 000000000..a5a7b944a --- /dev/null +++ b/src/winml/modelkit/telemetry/library/exporter.py @@ -0,0 +1,174 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +"""OneCollector log exporter: serializes OTel log records as CS 4.0 envelopes and POSTs them.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +import requests +from opentelemetry.sdk._logs.export import LogRecordExporter, LogRecordExportResult + +from .serialization import _build_envelope, _serialize_batch + + +if TYPE_CHECKING: + from collections.abc import Sequence + + from opentelemetry.sdk._logs import ReadableLogRecord + +_LOGGER = logging.getLogger(__name__) + +_HTTP_TIMEOUT = 10.0 + + +class OneCollectorLogExporter(LogRecordExporter): + """Post Common Schema 4.0 event envelopes to the OneCollector endpoint.""" + + def __init__(self, ikey: str, endpoint: str) -> None: + # Fail loudly rather than silently POST ``{"iKey": ""}`` to the + # endpoint. In dev installs ``constants.INSTRUMENTATION_KEY`` is + # empty; the Telemetry singleton guards against that, and this + # second guard keeps the invariant a property of the library + # itself (defense in depth). + if not ikey: + raise ValueError("ikey must be non-empty") + if not endpoint: + raise ValueError("endpoint must be non-empty") + self._ikey = ikey + self._endpoint = endpoint + # _shutdown is read on the BatchLogRecordProcessor export thread and + # written on the shutdown thread; bool assignment is atomic under the + # CPython GIL, so no lock is needed. + self._shutdown = False + # Close the session if post-creation setup fails, so we never leak a + # Session object (and its connection pool) when init raises. + session = requests.Session() + try: + session.headers.update( + { + "Content-Type": "application/json; charset=utf-8", + } + ) + except Exception: + session.close() + raise + self._session = session + + def export(self, batch: Sequence[ReadableLogRecord]) -> LogRecordExportResult: + """Serialize *batch* and POST to the configured OneCollector endpoint. + + Retries are **not** implemented at this layer; the upstream + ``BatchLogRecordProcessor`` re-queues batches for which ``export`` + returns ``FAILURE``. + """ + if self._shutdown or not batch: + return LogRecordExportResult.SUCCESS + + try: + envelopes = [self._to_envelope(ld) for ld in batch] + body = _serialize_batch(envelopes) + except Exception: + _LOGGER.debug("telemetry serialization failed", exc_info=True) + return LogRecordExportResult.FAILURE + + try: + response = self._session.post( + self._endpoint, + data=body, + timeout=_HTTP_TIMEOUT, + ) + except (requests.ConnectionError, requests.Timeout): + _LOGGER.debug("telemetry network failure", exc_info=True) + return LogRecordExportResult.FAILURE + + if 200 <= response.status_code < 300: + return LogRecordExportResult.SUCCESS + _LOGGER.debug("telemetry backend returned %s", response.status_code) + return LogRecordExportResult.FAILURE + + def force_flush(self, timeout_millis: int = 30_000) -> bool: + """No-op: all exports are synchronous.""" + return True + + def shutdown(self) -> None: + """Mark exporter as shut down and close the underlying HTTP session.""" + self._shutdown = True + try: + self._session.close() + except Exception: + _LOGGER.debug("session close failed", exc_info=True) + + # --- internal --- + + def _to_envelope(self, ld: ReadableLogRecord) -> dict: + record = ld.log_record + timestamp = _ns_to_datetime(record.timestamp) + data = dict(record.attributes or {}) + ext = _resource_to_ext(ld.resource) + return _build_envelope( + name=str(record.body), + ikey=self._ikey, + timestamp=timestamp, + data=data, + ext=ext, + ) + + +def _ns_to_datetime(ts_ns: int) -> datetime: + return datetime.fromtimestamp(ts_ns / 1_000_000_000, tz=timezone.utc) + + +def _resource_to_ext(resource) -> dict: + """Translate OpenTelemetry Resource attributes to CS 4.0 ext.* slots. + + Attribute name → CS slot mapping: + device_id → ext.device.localId + id_status → ext.device.authId + os.arch → ext.device.deviceClass + os.name → ext.os.name + os.version → ext.os.ver + os.release → ext.os.release + app_version → ext.app.ver + app_instance_id → ext.app.sesId + initTs → ext.app.initTs + """ + if resource is None: + return {} + attrs = dict(resource.attributes or {}) + ext: dict[str, dict] = {} + device: dict = {} + os_: dict = {} + app: dict = {} + + if "device_id" in attrs: + device["localId"] = attrs["device_id"] + if "id_status" in attrs: + device["authId"] = attrs["id_status"] + if "os.arch" in attrs: + device["deviceClass"] = attrs["os.arch"] + if "os.name" in attrs: + os_["name"] = attrs["os.name"] + if "os.version" in attrs: + os_["ver"] = attrs["os.version"] + if "os.release" in attrs: + os_["release"] = attrs["os.release"] + if "app_version" in attrs: + app["ver"] = attrs["app_version"] + if "app_instance_id" in attrs: + app["sesId"] = attrs["app_instance_id"] + if "initTs" in attrs: + app["initTs"] = attrs["initTs"] + + if device: + ext["device"] = device + if os_: + ext["os"] = os_ + if app: + ext["app"] = app + return ext diff --git a/src/winml/modelkit/telemetry/library/serialization.py b/src/winml/modelkit/telemetry/library/serialization.py new file mode 100644 index 000000000..aeeb9a3f6 --- /dev/null +++ b/src/winml/modelkit/telemetry/library/serialization.py @@ -0,0 +1,52 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +"""Common Schema 4.0 envelope builders for OneCollector events.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + from datetime import datetime + + +def _build_envelope( + name: str, + ikey: str, + timestamp: datetime, + data: dict[str, Any], + ext: dict[str, Any], +) -> dict[str, Any]: + """Build one CS 4.0 event envelope. + + Fields: + ver: schema version, always "4.0" + name: event name (e.g. "ModelKitAction") + time: ISO8601 UTC, millisecond precision, trailing Z + iKey: OneCollector InstrumentationKey (in "o:" form) + data: event-specific flat payload + ext: common context slots (os, app, device) + """ + return { + "ver": "4.0", + "name": name, + "time": _format_time(timestamp), + "iKey": ikey, + "data": data, + "ext": ext, + } + + +def _format_time(ts: datetime) -> str: + # CS 4.0 expects millisecond precision, trailing Z (UTC). + return ts.strftime("%Y-%m-%dT%H:%M:%S.") + f"{ts.microsecond // 1000:03d}Z" + + +def _serialize_batch(envelopes: list[dict[str, Any]]) -> bytes: + """Serialize a batch of envelopes as a compact JSON array.""" + return json.dumps(envelopes, separators=(",", ":"), ensure_ascii=False).encode("utf-8") diff --git a/tests/unit/telemetry/__init__.py b/tests/unit/telemetry/__init__.py new file mode 100644 index 000000000..862c45ce3 --- /dev/null +++ b/tests/unit/telemetry/__init__.py @@ -0,0 +1,4 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- diff --git a/tests/unit/telemetry/deviceid/__init__.py b/tests/unit/telemetry/deviceid/__init__.py new file mode 100644 index 000000000..862c45ce3 --- /dev/null +++ b/tests/unit/telemetry/deviceid/__init__.py @@ -0,0 +1,4 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- diff --git a/tests/unit/telemetry/deviceid/conftest.py b/tests/unit/telemetry/deviceid/conftest.py new file mode 100644 index 000000000..7d790eb3b --- /dev/null +++ b/tests/unit/telemetry/deviceid/conftest.py @@ -0,0 +1,31 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +"""Shared fixtures for deviceid unit tests.""" + +from __future__ import annotations + +import os + +import pytest + +from winml.modelkit.telemetry.deviceid import _store + + +@pytest.fixture +def isolated_store(monkeypatch): + """Redirect _store to a per-pid registry subkey so tests don't touch + real state. Best-effort cleanup on teardown.""" + subkey = rf"SOFTWARE\Microsoft\DeveloperTools\.modelkit-test-{os.getpid()}" + monkeypatch.setattr(_store, "_REGISTRY_KEY", subkey) + yield + import winreg + + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, subkey) + except OSError: + # Best-effort fixture cleanup; a teardown failure must not mask + # the test result. + return diff --git a/tests/unit/telemetry/deviceid/test_deviceid.py b/tests/unit/telemetry/deviceid/test_deviceid.py new file mode 100644 index 000000000..0e069e8a0 --- /dev/null +++ b/tests/unit/telemetry/deviceid/test_deviceid.py @@ -0,0 +1,67 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +import hashlib +import re + +from winml.modelkit.telemetry.deviceid import IdStatus, _store, get_or_create_device_id + + +_HEX64_RE = re.compile(r"^[0-9a-f]{64}$") + + +def test_fresh_state_generates_new_device_id(isolated_store): + device_id, status = get_or_create_device_id() + assert status is IdStatus.NEW + # SHA256 hex digest: 64 lowercase hex chars + assert _HEX64_RE.match(device_id) + + +def test_subsequent_call_returns_existing(isolated_store): + first_id, _ = get_or_create_device_id() + second_id, status = get_or_create_device_id() + assert status is IdStatus.EXISTING + assert second_id == first_id + + +def test_status_is_string_compatible_for_otel_resource(isolated_store): + # IdStatus subclasses str so callers can stuff it into an OTel Resource + # or the CS 4.0 ext.device.authId slot without .value conversion. + _, status = get_or_create_device_id() + assert isinstance(status, str) + assert status == "NEW" + + +def test_storage_write_failure_returns_failed(isolated_store, monkeypatch): + def boom(name, value): + raise OSError("disk full") + + monkeypatch.setattr(_store, "write_key", boom) + device_id, status = get_or_create_device_id() + assert status is IdStatus.FAILED + assert device_id == "" + + +def test_storage_read_failure_falls_through_to_new(isolated_store, monkeypatch): + def boom(name): + raise OSError("registry down") + + monkeypatch.setattr(_store, "read_key", boom) + # Writing still works; we should generate a NEW id rather than FAILED + device_id, status = get_or_create_device_id() + assert status is IdStatus.NEW + assert _HEX64_RE.match(device_id) + + +def test_hash_stability_across_calls(isolated_store): + # Same underlying UUID → same hex digest (regression guard against + # accidental dependence on bytes representation). + import uuid + + from winml.modelkit.telemetry.deviceid.deviceid import _hash_uuid + + u = uuid.UUID("12345678-1234-5678-1234-567812345678") + assert _hash_uuid(u) == _hash_uuid(u) + assert _hash_uuid(u) == hashlib.sha256(str(u).encode("utf-8")).hexdigest() diff --git a/tests/unit/telemetry/deviceid/test_public_api.py b/tests/unit/telemetry/deviceid/test_public_api.py new file mode 100644 index 000000000..75e459d58 --- /dev/null +++ b/tests/unit/telemetry/deviceid/test_public_api.py @@ -0,0 +1,25 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + + +def test_public_api_exposes_get_or_create_device_id(): + """External code imports from winml.modelkit.telemetry.deviceid, never + reaching into internal submodules.""" + from winml.modelkit.telemetry.deviceid import get_or_create_device_id + + assert callable(get_or_create_device_id) + + +def test_public_api_exposes_id_status(): + from winml.modelkit.telemetry.deviceid import IdStatus + + assert {m.name for m in IdStatus} == {"EXISTING", "NEW", "FAILED"} + + +def test_store_is_not_re_exported(): + """Private submodule — should not appear in the package __all__.""" + import winml.modelkit.telemetry.deviceid as pkg + + assert "_store" not in getattr(pkg, "__all__", []) diff --git a/tests/unit/telemetry/deviceid/test_store.py b/tests/unit/telemetry/deviceid/test_store.py new file mode 100644 index 000000000..bd8e7d267 --- /dev/null +++ b/tests/unit/telemetry/deviceid/test_store.py @@ -0,0 +1,43 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +from winml.modelkit.telemetry.deviceid import _store + + +def test_write_then_read_returns_value(isolated_store): + _store.write_key("deviceid", "abc123") + assert _store.read_key("deviceid") == "abc123" + + +def test_read_absent_returns_none(isolated_store): + assert _store.read_key("missing") is None + + +def test_delete_removes_value(isolated_store): + _store.write_key("deviceid", "abc123") + _store.delete_key("deviceid") + assert _store.read_key("deviceid") is None + + +def test_delete_absent_is_silent(isolated_store): + _store.delete_key("never-existed") # must not raise + + +def test_overwrite_updates_value(isolated_store): + _store.write_key("deviceid", "first") + _store.write_key("deviceid", "second") + assert _store.read_key("deviceid") == "second" + + +def test_read_registry_ignores_non_string_values(isolated_store): + """Regression guard: externally planted REG_DWORD or REG_BINARY must not + be coerced to a surprise string.""" + import winreg + + # Write a REG_DWORD under our per-pid test key. + with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, _store._REGISTRY_KEY) as key: + winreg.SetValueEx(key, "deviceid", 0, winreg.REG_DWORD, 12345) + # Reading via _store.read_key should treat this as absent (None). + assert _store.read_key("deviceid") is None diff --git a/tests/unit/telemetry/library/__init__.py b/tests/unit/telemetry/library/__init__.py new file mode 100644 index 000000000..862c45ce3 --- /dev/null +++ b/tests/unit/telemetry/library/__init__.py @@ -0,0 +1,4 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- diff --git a/tests/unit/telemetry/library/test_exporter.py b/tests/unit/telemetry/library/test_exporter.py new file mode 100644 index 000000000..07ee8c81e --- /dev/null +++ b/tests/unit/telemetry/library/test_exporter.py @@ -0,0 +1,189 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +# NOTE: The plan was written against opentelemetry-sdk 1.39.1 which exported +# LogData, LogExporter, and LogExportResult. The installed SDK (1.41+) has +# renamed these to ReadableLogRecord, LogRecordExporter, and +# LogRecordExportResult (the old names are deprecated aliases). Tests use the +# current names throughout. + +from unittest.mock import MagicMock, patch + +import pytest +import requests +from opentelemetry._logs import LogRecord +from opentelemetry.sdk._logs import ReadableLogRecord +from opentelemetry.sdk._logs.export import LogRecordExportResult +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + +from winml.modelkit.telemetry.library import OneCollectorLogExporter + + +def _make_log_data(body: str, attrs: dict, resource: Resource | None = None) -> ReadableLogRecord: + record = LogRecord( + timestamp=1_712_678_400_000_000_000, # ns + body=body, + attributes=attrs, + ) + return ReadableLogRecord( + log_record=record, + resource=resource or Resource.get_empty(), + instrumentation_scope=InstrumentationScope("modelkit"), + ) + + +@pytest.fixture +def exporter(): + return OneCollectorLogExporter( + ikey="o:abc", + endpoint="https://example.invalid/OneCollector/1.0/", + ) + + +@pytest.mark.parametrize( + "ikey,endpoint", + [ + ("", "https://example.invalid/"), + ("o:abc", ""), + ("", ""), + ], +) +def test_constructor_rejects_empty_ikey_or_endpoint(ikey, endpoint): + """Defense-in-depth: no accidental POST with an empty iKey, even if + the Telemetry singleton's gating is bypassed (e.g. direct instantiation + from a test or a future callsite).""" + with pytest.raises(ValueError): + OneCollectorLogExporter(ikey=ikey, endpoint=endpoint) + + +def test_export_success_returns_success(exporter): + ld = _make_log_data( + body="ModelKitAction", + attrs={"action_name": "build", "success": True}, + ) + mock_response = MagicMock(status_code=200) + with patch.object(exporter._session, "post", return_value=mock_response) as mock_post: + result = exporter.export([ld]) + + assert result == LogRecordExportResult.SUCCESS + mock_post.assert_called_once() + # Content-Type is set on the session (applied to every request). + assert exporter._session.headers["Content-Type"].startswith("application/json") + # Body is JSON array containing the event + _, kwargs = mock_post.call_args + body = kwargs["data"] + assert b'"ModelKitAction"' in body + assert b'"iKey":"o:abc"' in body + + +def test_export_connection_error_returns_failure(exporter): + ld = _make_log_data("ModelKitHeartbeat", {}) + with patch.object(exporter._session, "post", side_effect=requests.ConnectionError("no route")): + result = exporter.export([ld]) + assert result == LogRecordExportResult.FAILURE + + +def test_export_timeout_returns_failure(exporter): + ld = _make_log_data("ModelKitHeartbeat", {}) + with patch.object(exporter._session, "post", side_effect=requests.Timeout("slow")): + result = exporter.export([ld]) + assert result == LogRecordExportResult.FAILURE + + +def test_export_4xx_5xx_returns_failure(exporter): + ld = _make_log_data("ModelKitHeartbeat", {}) + for status in (400, 500, 503): + with patch.object(exporter._session, "post", return_value=MagicMock(status_code=status)): + result = exporter.export([ld]) + assert result == LogRecordExportResult.FAILURE, f"status {status}" + + +def test_export_translates_resource_to_ext(exporter): + resource = Resource.create( + { + "device_id": "hash-abc", + "id_status": "EXISTING", + "os.name": "Windows", + "os.version": "10.0.26200", + "os.release": "11", + "os.arch": "AMD64", + "app_version": "0.0.1", + "app_instance_id": "sesid-xyz", + "initTs": 1712678400.0, + } + ) + ld = _make_log_data("ModelKitHeartbeat", {}, resource=resource) + + captured_body = {} + + def fake_post(url, data=None, headers=None, timeout=None): + captured_body["data"] = data + return MagicMock(status_code=200) + + with patch.object(exporter._session, "post", side_effect=fake_post): + exporter.export([ld]) + + import json + + envelopes = json.loads(captured_body["data"].decode("utf-8")) + ext = envelopes[0]["ext"] + assert ext["device"] == { + "localId": "hash-abc", + "authId": "EXISTING", + "deviceClass": "AMD64", + } + assert ext["os"] == {"name": "Windows", "ver": "10.0.26200", "release": "11"} + assert ext["app"] == { + "ver": "0.0.1", + "sesId": "sesid-xyz", + "initTs": 1712678400.0, + } + + +def test_shutdown_closes_session_and_blocks_further_exports(exporter): + close_called = MagicMock() + exporter._session.close = close_called + + exporter.shutdown() + close_called.assert_called_once() + + # After shutdown, export is a no-op that returns SUCCESS + ld = _make_log_data("ModelKitHeartbeat", {}) + with patch.object(exporter._session, "post") as mock_post: + assert exporter.export([ld]) == LogRecordExportResult.SUCCESS + mock_post.assert_not_called() + + +def test_export_empty_batch_is_noop(exporter): + with patch.object(exporter._session, "post") as mock_post: + assert exporter.export([]) == LogRecordExportResult.SUCCESS + mock_post.assert_not_called() + + +# --- _resource_to_ext direct unit tests --- + + +from winml.modelkit.telemetry.library.exporter import _resource_to_ext # noqa: E402 + + +def test_resource_to_ext_none_returns_empty_dict(): + assert _resource_to_ext(None) == {} + + +def test_resource_to_ext_empty_resource_returns_empty_dict(): + assert _resource_to_ext(Resource.create({})) == {} + + +def test_resource_to_ext_partial_only_populates_present_slots(): + # Only an os.name is present → only the "os" sub-dict should appear. + ext = _resource_to_ext(Resource.create({"os.name": "Windows"})) + assert ext == {"os": {"name": "Windows"}} + + +def test_resource_to_ext_unknown_attributes_are_ignored(): + # Attributes outside the mapping table do not leak into ext. + ext = _resource_to_ext(Resource.create({"os.name": "Windows", "custom.attr": "x"})) + assert ext == {"os": {"name": "Windows"}} diff --git a/tests/unit/telemetry/library/test_factory.py b/tests/unit/telemetry/library/test_factory.py new file mode 100644 index 000000000..727759058 --- /dev/null +++ b/tests/unit/telemetry/library/test_factory.py @@ -0,0 +1,56 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +"""Tests for the LoggerProvider factory.""" + +from collections.abc import Callable + +import pytest +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.resources import Resource + +from winml.modelkit.telemetry.library import ( + DEFAULT_ENDPOINT, + create_logger_provider, +) + + +@pytest.fixture +def make_provider() -> Callable[..., LoggerProvider]: + """Build providers that are guaranteed to shut down after the test. + + `create_logger_provider` wires a `BatchLogRecordProcessor` which spawns a + background daemon thread; without `shutdown()` each test leaks one. + """ + created: list[LoggerProvider] = [] + + def _make(**kwargs) -> LoggerProvider: + provider = create_logger_provider(**kwargs) + created.append(provider) + return provider + + yield _make + + for provider in created: + provider.shutdown() + + +def test_default_endpoint_points_at_one_collector(): + assert DEFAULT_ENDPOINT == ("https://mobile.events.data.microsoft.com/OneCollector/1.0/") + + +def test_create_logger_provider_returns_configured_provider(make_provider): + resource = Resource.create({"app_version": "0.0.1"}) + provider = make_provider(ikey="o:test", resource=resource) + assert isinstance(provider, LoggerProvider) + assert provider.resource.attributes.get("app_version") == "0.0.1" + + +def test_create_logger_provider_with_custom_endpoint(make_provider): + provider = make_provider( + ikey="o:test", + endpoint="https://example.invalid/OneCollector/1.0/", + ) + assert isinstance(provider, LoggerProvider) diff --git a/tests/unit/telemetry/library/test_serialization.py b/tests/unit/telemetry/library/test_serialization.py new file mode 100644 index 000000000..052fe476f --- /dev/null +++ b/tests/unit/telemetry/library/test_serialization.py @@ -0,0 +1,69 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# -------------------------------------------------------------------------- + +from datetime import datetime, timezone + +import pytest + +from winml.modelkit.telemetry.library.serialization import _build_envelope, _serialize_batch + + +def test_build_envelope_basic_shape(): + ts = datetime(2026, 4, 17, 10, 30, 0, 123456, tzinfo=timezone.utc) + envelope = _build_envelope( + name="ModelKitAction", + ikey="o:abc-def", + timestamp=ts, + data={"action_name": "build", "success": True}, + ext={"app": {"ver": "0.0.1"}}, + ) + assert envelope["ver"] == "4.0" + assert envelope["name"] == "ModelKitAction" + assert envelope["iKey"] == "o:abc-def" + assert envelope["data"] == {"action_name": "build", "success": True} + assert envelope["ext"] == {"app": {"ver": "0.0.1"}} + # ISO8601 with millisecond precision, trailing Z for UTC + assert envelope["time"] == "2026-04-17T10:30:00.123Z" + + +def test_serialize_batch_emits_json_array(): + ts = datetime(2026, 4, 17, 10, 30, 0, 0, tzinfo=timezone.utc) + envelopes = [ + _build_envelope("ModelKitHeartbeat", "o:key", ts, {}, {}), + _build_envelope("ModelKitAction", "o:key", ts, {"success": True}, {}), + ] + body = _serialize_batch(envelopes) + # Compact JSON, no whitespace + assert body.startswith(b"[") + assert body.endswith(b"]") + # Both events present + assert b'"ModelKitHeartbeat"' in body + assert b'"ModelKitAction"' in body + # UTF-8 encoded + assert isinstance(body, bytes) + + +def test_serialize_batch_preserves_unicode(): + ts = datetime(2026, 4, 17, 10, 30, 0, 0, tzinfo=timezone.utc) + envelope = _build_envelope("ModelKitAction", "o:key", ts, {"note": "café λ"}, {}) + body = _serialize_batch([envelope]) + # ensure_ascii=False keeps unicode readable + assert "café".encode() in body + assert "λ".encode() in body + + +@pytest.mark.parametrize( + "microsecond,expected_ms", + [ + (0, "000"), + (1000, "001"), + (999999, "999"), + (500000, "500"), + ], +) +def test_timestamp_millisecond_precision(microsecond, expected_ms): + ts = datetime(2026, 4, 17, 10, 30, 0, microsecond, tzinfo=timezone.utc) + envelope = _build_envelope("X", "o:k", ts, {}, {}) + assert envelope["time"] == f"2026-04-17T10:30:00.{expected_ms}Z"