Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 61 additions & 16 deletions src/pyetwkit/exporters/otlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
94 changes: 94 additions & 0 deletions tests/test_otlp_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Loading