Repository: open-telemetry/opentelemetry-python

Issue:
Events should be immutable and should preserve the order in which they are set
https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/api.md#add-events


Discussion:
Hi there! I was interested in trying my hand at this but had a couple of questions first.

When the specification says

> The `Event` SHOULD be an immutable type.

obviously that means `e._name = 'new name'` should fail, but does it also mean that `e._attributes[0]['something'] = 'edited'` should fail?

And when the specification says

> Events SHOULD preserve the order in which they are recorded. This will typically match the ordering of the events' timestamps, but events may be recorded out-of-order using custom timestamps.

I read that as meaning that an event could appear in the list of events after another event that had a later `timestamp`, like this:

```python
s._add_event(Event('a', timestamp=1))
s._add_event(Event('b', timestamp=3))
s._add_event(Event('c', timestamp=2))
list(s.events)
>>> [Event('a', timestamp=1), Event('b', timestamp=3), Event('c', timestamp=2)]
```

Is that correct or would we expect `Event('c', timestamp=2)` to appear before `Event('b', timestamp=3)` in `events`? I took a quick look at the JS libraries and couldn't see them doing that anywhere but wanted to double-check.
> obviously that means e._name = 'new name' should fail, but does it also mean that e._attributes[0]['something'] = 'edited' should fail?

I believe so.

> Is that correct or would we expect Event('c', timestamp=2) to appear before Event('b', timestamp=3) in events?

I believe the former.
Ok, maybe recursively transforming `_attributes` into nested tuples will be necessary in that case? I can try that out but it seems less idiomatic than overriding `__setattr__` and `__delattr__`.

If `events` is already ordered as required by the specification perhaps this issue can be renamed to be solely about event immutability?
You can do something similar to [this](https://github.com/open-telemetry/opentelemetry-python/pull/1134) or simply use nested tuples. I don't think you can override `__setattr__` because then you won't be able to set anything in the `__init__` method.

I believe your second question is correct, only about immutability if order is already preserved.
I just want to make sure we want to go this far into the internals. As the code stands right now something like this will fail:

```python
with self.tracer.start_as_current_span("root") as root:
    root.add_event("event0")

root.events[0].name = "event1"
>>> AttributeError: can't set attribute
```

This is because we already wrap most of the properties of `Event` and `EventBase` in `@property` decorators, so any attempt to directly change `attributes`, `name` or `timestamp` will fail. I feel like for those top-level properties we don't need to prevent someone from tinkering with `_attributes`, `_name` or `_timestamp`? This comment from the PR you linked to puts it well:

> [I don't think we need to make it literally impossible to modify the object. Given this is Python, I think anyone included to do it will find a way. We should just make sure we inform the person that they shouldn't do it. If they want to go out of their way and still modify, I think we should probably allow that.](https://github.com/open-telemetry/opentelemetry-python/pull/1134/files#r494317479)

What I _do_ think still needs to be addressed is something like this:

```python
with self.tracer.start_as_current_span("root") as root:
    root.add_event("event0", {"name": ["birthday"]})

root.events[0].attributes["name"][0] = "hello"  # Fails because `root.events[0].attributes["name"]` is a tuple
root.events[0].attributes["name"] = "hello"     # Succeeds because `root.events[0].attributes` is a dict
```

You can assign this issue to me and I can work on that specific example of editing the _contents_ of `attributes` if you agree with the change in focus.
I think we would still want to prevent users from modifying the dict elements like in `root.events[0].attributes["name"] = "hello"  `.
I believe you can do this by utilizing a `MappingProxyType` similar to what we do in [baggage](https://github.com/open-telemetry/opentelemetry-python/blob/master/opentelemetry-api/src/opentelemetry/baggage/__init__.py#L37).

Resolving patch:
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
@@ -18,12 +18,11 @@
 import concurrent.futures
 import json
 import logging
-import random
 import threading
 import traceback
 from collections import OrderedDict
 from contextlib import contextmanager
-from types import TracebackType
+from types import MappingProxyType, TracebackType
 from typing import (
     Any,
     Callable,
@@ -340,6 +339,10 @@ def _filter_attribute_values(attributes: types.Attributes):
                 attributes.pop(attr_key)
 
 
+def _create_immutable_attributes(attributes):
+    return MappingProxyType(attributes.copy() if attributes else {})
+
+
 class Span(trace_api.Span):
     """See `opentelemetry.trace.Span`.
 
@@ -408,6 +411,10 @@ def __init__(
         if events:
             for event in events:
                 _filter_attribute_values(event.attributes)
+                # pylint: disable=protected-access
+                event._attributes = _create_immutable_attributes(
+                    event.attributes
+                )
                 self.events.append(event)
 
         if links is None:
@@ -566,8 +573,7 @@ def add_event(
         timestamp: Optional[int] = None,
     ) -> None:
         _filter_attribute_values(attributes)
-        if not attributes:
-            attributes = self._new_attributes()
+        attributes = _create_immutable_attributes(attributes)
         self._add_event(
             Event(
                 name=name,


Test patch:
diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py
--- a/opentelemetry-sdk/tests/trace/test_trace.py
+++ b/opentelemetry-sdk/tests/trace/test_trace.py
@@ -590,7 +590,6 @@ def test_events(self):
             root.add_event("event0")
 
             # event name and attributes
-            now = time_ns()
             root.add_event(
                 "event1", {"name": "pluto", "some_bools": [True, False]}
             )
@@ -628,6 +627,30 @@ def test_events(self):
                 root.events[3].attributes, {"name": ("original_contents",)}
             )
 
+    def test_events_are_immutable(self):
+        event_properties = [
+            prop for prop in dir(trace.EventBase) if not prop.startswith("_")
+        ]
+
+        with self.tracer.start_as_current_span("root") as root:
+            root.add_event("event0", {"name": ["birthday"]})
+            event = root.events[0]
+
+            for prop in event_properties:
+                with self.assertRaises(AttributeError):
+                    setattr(event, prop, "something")
+
+    def test_event_attributes_are_immutable(self):
+        with self.tracer.start_as_current_span("root") as root:
+            root.add_event("event0", {"name": ["birthday"]})
+            event = root.events[0]
+
+            with self.assertRaises(TypeError):
+                event.attributes["name"][0] = "happy"
+
+            with self.assertRaises(TypeError):
+                event.attributes["name"] = "hello"
+
     def test_invalid_event_attributes(self):
         self.assertEqual(trace_api.get_current_span(), trace_api.INVALID_SPAN)