diff --git a/src/pyetwkit/exporters/otlp.py b/src/pyetwkit/exporters/otlp.py index 4b0c61d..b7aaa71 100644 --- a/src/pyetwkit/exporters/otlp.py +++ b/src/pyetwkit/exporters/otlp.py @@ -11,6 +11,7 @@ import re import time import uuid +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime from enum import Enum @@ -37,6 +38,48 @@ STATUS_CODE_OK = 1 +# The field names an event is expected to carry. Used both to read values and +# to tell an event from something that is not one at all. +_EVENT_FIELDS = ( + "event_id", + "provider_name", + "timestamp", + "process_id", + "thread_id", + "properties", +) + + +def _event_field(event: Any, name: str, default: Any) -> Any: + """Read one field, whether the event is a mapping or an object. + + `getattr` alone quietly returned the default for every field of a `dict`, so + a dict event produced a span named "unknown.0" with no provider and no PID -- + no error, just wrong. `pyetwkit.export` has always accepted both shapes; this + brings the OTLP side into line with it. + """ + if isinstance(event, Mapping): + value = event.get(name, default) + else: + value = getattr(event, name, default) + return default if value is None else value + + +def _require_event(event: Any) -> None: + """Reject something that is not an event at all. + + Without this a string or an int would sail through and produce a span of + defaults, which is the failure this whole change is about. + """ + if isinstance(event, Mapping): + return + if any(hasattr(event, field) for field in _EVENT_FIELDS): + return + raise TypeError( + f"expected an ETW event or a mapping of event fields, got {type(event).__name__}" + ) + + def _timestamp_seconds(raw: Any) -> float: """Seconds since the epoch, from whatever an event carries. @@ -166,8 +209,8 @@ def get_span_name(self, event: Any) -> str | None: Returns: Span name or None if no rule matches. """ - provider = getattr(event, "provider_name", "") - event_id = getattr(event, "event_id", 0) + provider = _event_field(event, "provider_name", "") + event_id = _event_field(event, "event_id", 0) for rule in self._rules: if rule.provider == provider and rule.event_id == event_id: @@ -184,9 +227,9 @@ def extract_attributes(self, event: Any) -> dict[str, Any]: Returns: Dictionary of attributes. """ - provider = getattr(event, "provider_name", "") - event_id = getattr(event, "event_id", 0) - properties = getattr(event, "properties", {}) + provider = _event_field(event, "provider_name", "") + event_id = _event_field(event, "event_id", 0) + properties = _event_field(event, "properties", {}) for rule in self._rules: if rule.provider == provider and rule.event_id == event_id: @@ -550,12 +593,13 @@ def event_to_span( Returns: Span dictionary in OTLP format. """ - event_id = getattr(event, "event_id", 0) - provider_name = getattr(event, "provider_name", "unknown") - raw_timestamp = getattr(event, "timestamp", time.time()) - process_id = getattr(event, "process_id", 0) - thread_id = getattr(event, "thread_id", 0) - properties = getattr(event, "properties", {}) + _require_event(event) + event_id = _event_field(event, "event_id", 0) + provider_name = _event_field(event, "provider_name", "unknown") + raw_timestamp = _event_field(event, "timestamp", time.time()) + process_id = _event_field(event, "process_id", 0) + thread_id = _event_field(event, "thread_id", 0) + properties = _event_field(event, "properties", {}) timestamp = _timestamp_seconds(raw_timestamp) @@ -591,11 +635,12 @@ def event_to_log( Returns: Log dictionary in OTLP format. """ - event_id = getattr(event, "event_id", 0) - provider_name = getattr(event, "provider_name", "unknown") - raw_timestamp = getattr(event, "timestamp", time.time()) - process_id = getattr(event, "process_id", 0) - properties = getattr(event, "properties", {}) + _require_event(event) + event_id = _event_field(event, "event_id", 0) + provider_name = _event_field(event, "provider_name", "unknown") + raw_timestamp = _event_field(event, "timestamp", time.time()) + process_id = _event_field(event, "process_id", 0) + properties = _event_field(event, "properties", {}) timestamp = _timestamp_seconds(raw_timestamp) diff --git a/tests/test_otlp_exporter.py b/tests/test_otlp_exporter.py index 43e6676..895a99e 100644 --- a/tests/test_otlp_exporter.py +++ b/tests/test_otlp_exporter.py @@ -375,3 +375,97 @@ def test_file_exporter_formats(self) -> None: assert hasattr(OtlpFileFormat, "JSON") assert hasattr(OtlpFileFormat, "PROTOBUF") + + +EVENT_FIELDS = { + "provider_name": "Microsoft-Windows-Kernel-Process", + "event_id": 1, + "process_id": 4104, + "thread_id": 512, + "timestamp": 1788613764.0, + "properties": {"ImageName": "cmd.exe"}, +} + + +def _attrs(span: dict) -> dict: + return {a["key"]: a["value"] for a in span["attributes"]} + + +class TestEventShapes: + """A mapping and an object describing the same event must agree. + + `event_to_span` read events with `getattr` only, so a dict produced a span + named "unknown.0" with no provider and no PID -- no error, just wrong. + `pyetwkit.export` has always accepted both shapes. + """ + + def test_event_to_span_reads_a_dict_the_same_as_an_object(self) -> None: + from types import SimpleNamespace + + from pyetwkit.exporters import event_to_span + + from_dict = event_to_span(dict(EVENT_FIELDS)) + from_object = event_to_span(SimpleNamespace(**EVENT_FIELDS)) + + assert from_dict["name"] == from_object["name"] == "Microsoft-Windows-Kernel-Process.1" + assert from_dict["startTimeUnixNano"] == from_object["startTimeUnixNano"] + + for key in ("etw.provider", "etw.event_id", "process.pid", "thread.id", "etw.ImageName"): + assert _attrs(from_dict)[key] == _attrs(from_object)[key], key + + def test_event_to_log_reads_a_dict_too(self) -> None: + from types import SimpleNamespace + + from pyetwkit.exporters import event_to_log + + from_dict = event_to_log(dict(EVENT_FIELDS)) + from_object = event_to_log(SimpleNamespace(**EVENT_FIELDS)) + + assert from_dict["timeUnixNano"] == from_object["timeUnixNano"] + + def test_span_mapper_matches_a_dict(self) -> None: + from pyetwkit.exporters import SpanMapper + + mapper = SpanMapper() + mapper.add_rule( + provider="Microsoft-Windows-Kernel-Process", + event_id=1, + span_name="process.start", + attributes=["ImageName"], + ) + + assert mapper.get_span_name(dict(EVENT_FIELDS)) == "process.start" + assert mapper.extract_attributes(dict(EVENT_FIELDS)) == {"ImageName": "cmd.exe"} + + def test_a_dict_event_survives_the_whole_exporter(self) -> None: + """The path a caller actually takes, not just the helper.""" + import json + import tempfile + from pathlib import Path + + from pyetwkit.exporters import OtlpFileExporter + + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "traces.json" + exporter = OtlpFileExporter(str(out)) + exporter.export(dict(EVENT_FIELDS)) + assert exporter.flush() is True + + span = json.loads(out.read_text(encoding="utf-8"))["spans"][0] + assert span["name"] == "Microsoft-Windows-Kernel-Process.1" + + def test_something_that_is_not_an_event_is_rejected(self) -> None: + """Fail rather than return a span of defaults.""" + from pyetwkit.exporters import event_to_span + + for junk in ("not an event", 42, None): + with pytest.raises(TypeError): + event_to_span(junk) + + def test_a_missing_field_still_falls_back(self) -> None: + """A partial event is not junk; it just uses defaults for what is absent.""" + from pyetwkit.exporters import event_to_span + + span = event_to_span({"provider_name": "P", "event_id": 7}) + assert span["name"] == "P.7" + assert _attrs(span)["process.pid"] == {"intValue": 0}