mirror of
https://github.com/katanemo/plano.git
synced 2026-06-02 14:35:14 +02:00
signals v2 phase 1: introduce SignalEvent stream and emit as OTel span events
Made-with: Cursor
This commit is contained in:
parent
743d074184
commit
66819df153
8 changed files with 1078 additions and 16 deletions
|
|
@ -428,6 +428,27 @@ def _anyvalue_to_python(value_obj: Any) -> Any:
|
|||
return None
|
||||
|
||||
|
||||
def _kv_to_attr_dict(kv: Any) -> dict[str, Any] | None:
|
||||
"""Convert a protobuf KeyValue into the {"key", "value"} dict shape used
|
||||
internally. Returns None if the value type is unsupported."""
|
||||
py_val = _anyvalue_to_python(kv.value)
|
||||
if py_val is None:
|
||||
return None
|
||||
value_dict: dict[str, Any] = {}
|
||||
if isinstance(py_val, bool):
|
||||
# bool check must come before int, since bool is a subclass of int.
|
||||
value_dict["boolValue"] = py_val
|
||||
elif isinstance(py_val, str):
|
||||
value_dict["stringValue"] = py_val
|
||||
elif isinstance(py_val, int):
|
||||
value_dict["intValue"] = str(py_val)
|
||||
elif isinstance(py_val, float):
|
||||
value_dict["doubleValue"] = py_val
|
||||
else:
|
||||
return None
|
||||
return {"key": kv.key, "value": value_dict}
|
||||
|
||||
|
||||
def _proto_span_to_dict(span: Any, service_name: str) -> dict[str, Any]:
|
||||
"""Convert a protobuf Span message to the dict format used internally."""
|
||||
span_dict: dict[str, Any] = {
|
||||
|
|
@ -439,20 +460,29 @@ def _proto_span_to_dict(span: Any, service_name: str) -> dict[str, Any]:
|
|||
"endTimeUnixNano": str(span.end_time_unix_nano),
|
||||
"service": service_name,
|
||||
"attributes": [],
|
||||
"events": [],
|
||||
}
|
||||
for kv in span.attributes:
|
||||
py_val = _anyvalue_to_python(kv.value)
|
||||
if py_val is not None:
|
||||
value_dict: dict[str, Any] = {}
|
||||
if isinstance(py_val, str):
|
||||
value_dict["stringValue"] = py_val
|
||||
elif isinstance(py_val, bool):
|
||||
value_dict["boolValue"] = py_val
|
||||
elif isinstance(py_val, int):
|
||||
value_dict["intValue"] = str(py_val)
|
||||
elif isinstance(py_val, float):
|
||||
value_dict["doubleValue"] = py_val
|
||||
span_dict["attributes"].append({"key": kv.key, "value": value_dict})
|
||||
attr = _kv_to_attr_dict(kv)
|
||||
if attr is not None:
|
||||
span_dict["attributes"].append(attr)
|
||||
|
||||
# Preserve span events (name, timestamp, attributes). OTel span events are
|
||||
# the drill-down channel for granular signals that aggregate span
|
||||
# attributes summarize. Dropping them here would make every
|
||||
# per-detection `SignalEvent` emitted by brightstaff invisible to
|
||||
# `planoai trace`.
|
||||
for event in span.events:
|
||||
event_dict: dict[str, Any] = {
|
||||
"name": event.name,
|
||||
"timeUnixNano": str(event.time_unix_nano),
|
||||
"attributes": [],
|
||||
}
|
||||
for kv in event.attributes:
|
||||
attr = _kv_to_attr_dict(kv)
|
||||
if attr is not None:
|
||||
event_dict["attributes"].append(attr)
|
||||
span_dict["events"].append(event_dict)
|
||||
return span_dict
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,89 @@ class _FakeGrpcServer:
|
|||
return None
|
||||
|
||||
|
||||
def test_proto_span_to_dict_preserves_span_events():
|
||||
"""Span events are the drill-down channel for granular signals. The OTLP
|
||||
store must preserve them so `planoai trace` can surface per-detection
|
||||
SignalEvent payloads alongside aggregate signals.* attributes."""
|
||||
from opentelemetry.proto.common.v1 import common_pb2
|
||||
from opentelemetry.proto.trace.v1 import trace_pb2
|
||||
|
||||
span = trace_pb2.Span(
|
||||
trace_id=bytes.fromhex("0123456789abcdef0123456789abcdef"),
|
||||
span_id=bytes.fromhex("1111111111111111"),
|
||||
parent_span_id=b"",
|
||||
name="POST /v1/chat/completions gpt-4",
|
||||
start_time_unix_nano=1_700_000_000_000_000_000,
|
||||
end_time_unix_nano=1_700_000_000_500_000_000,
|
||||
attributes=[
|
||||
common_pb2.KeyValue(
|
||||
key="signals.quality",
|
||||
value=common_pb2.AnyValue(string_value="Neutral"),
|
||||
),
|
||||
],
|
||||
events=[
|
||||
trace_pb2.Span.Event(
|
||||
time_unix_nano=1_700_000_000_100_000_000,
|
||||
name="signal.interaction.frustration",
|
||||
attributes=[
|
||||
common_pb2.KeyValue(
|
||||
key="signal.event_id",
|
||||
value=common_pb2.AnyValue(
|
||||
string_value="01HF4ZABCDEF0123456789ABCD"
|
||||
),
|
||||
),
|
||||
common_pb2.KeyValue(
|
||||
key="signal.source_message_idx",
|
||||
value=common_pb2.AnyValue(int_value=3),
|
||||
),
|
||||
common_pb2.KeyValue(
|
||||
key="signal.evidence.snippet",
|
||||
value=common_pb2.AnyValue(string_value="WHY"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
span_dict = trace_cmd._proto_span_to_dict(span, "plano(llm)")
|
||||
|
||||
assert span_dict["name"] == "POST /v1/chat/completions gpt-4"
|
||||
assert span_dict["attributes"] == [
|
||||
{"key": "signals.quality", "value": {"stringValue": "Neutral"}}
|
||||
]
|
||||
|
||||
assert "events" in span_dict, "span events must be preserved"
|
||||
assert len(span_dict["events"]) == 1
|
||||
event = span_dict["events"][0]
|
||||
assert event["name"] == "signal.interaction.frustration"
|
||||
assert event["timeUnixNano"] == "1700000000100000000"
|
||||
|
||||
event_attrs = {a["key"]: a["value"] for a in event["attributes"]}
|
||||
assert event_attrs["signal.event_id"] == {
|
||||
"stringValue": "01HF4ZABCDEF0123456789ABCD"
|
||||
}
|
||||
assert event_attrs["signal.source_message_idx"] == {"intValue": "3"}
|
||||
assert event_attrs["signal.evidence.snippet"] == {"stringValue": "WHY"}
|
||||
|
||||
|
||||
def test_proto_span_to_dict_no_events_yields_empty_list():
|
||||
"""Spans without events should still produce an `events: []` key so
|
||||
downstream code can treat it as always present."""
|
||||
from opentelemetry.proto.trace.v1 import trace_pb2
|
||||
|
||||
span = trace_pb2.Span(
|
||||
trace_id=bytes.fromhex("0123456789abcdef0123456789abcdef"),
|
||||
span_id=bytes.fromhex("2222222222222222"),
|
||||
parent_span_id=b"",
|
||||
name="POST /v1/chat/completions",
|
||||
start_time_unix_nano=1_700_000_000_000_000_000,
|
||||
end_time_unix_nano=1_700_000_000_100_000_000,
|
||||
)
|
||||
|
||||
span_dict = trace_cmd._proto_span_to_dict(span, "plano(llm)")
|
||||
assert span_dict["events"] == []
|
||||
|
||||
|
||||
def test_start_trace_server_raises_bind_error(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
trace_cmd.grpc, "server", lambda *_args, **_kwargs: _FakeGrpcServer()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue