Repository: elastic/ecs-logging-python

Issue:
Update ecs.version to match spec
The [updated spec](https://github.com/elastic/ecs-logging-python/pull/87) expects `ecs.version` to be dotted, not nested, to match `log.level`.

Discussion:

Patch:
diff --git a/.github/workflows/periodic.yml b/.github/workflows/periodic.yml
index 89fee9f..adc42e9 100644
--- a/.github/workflows/periodic.yml
+++ b/.github/workflows/periodic.yml
@@ -15,7 +15,7 @@ jobs:
     timeout-minutes: 10
     strategy:
       matrix:
-        python: [ '3.7', '3.8', '3.9', '3.10' ]
+        python: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ]
       fail-fast: false
     steps:
     - uses: actions/checkout@v4
diff --git a/ecs_logging/_stdlib.py b/ecs_logging/_stdlib.py
index c0fed3a..9eff74d 100644
--- a/ecs_logging/_stdlib.py
+++ b/ecs_logging/_stdlib.py
@@ -209,7 +209,12 @@ class StdlibFormatter(logging.Formatter):
                 continue
             value = extractors[field](record)
             if value is not None:
-                merge_dicts(de_dot(field, value), result)
+                # special case ecs.version that should not be de-dotted
+                if field == "ecs.version":
+                    field_dict = {field: value}
+                else:
+                    field_dict = de_dot(field, value)
+                merge_dicts(field_dict, result)
 
         available = record.__dict__
 
diff --git a/ecs_logging/_structlog.py b/ecs_logging/_structlog.py
index a701c23..84877d7 100644
--- a/ecs_logging/_structlog.py
+++ b/ecs_logging/_structlog.py
@@ -55,7 +55,7 @@ class StructlogFormatter:
             else:
                 event_dict["error"] = {"stack_trace": stack_trace}
 
-        event_dict.setdefault("ecs", {}).setdefault("version", ECS_VERSION)
+        event_dict.setdefault("ecs.version", ECS_VERSION)
         return event_dict
 
     def _json_dumps(self, value):
diff --git a/noxfile.py b/noxfile.py
index a0a7082..cafd240 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -36,7 +36,7 @@ def tests_impl(session):
     )
 
 
-@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10"])
+@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"])
 def test(session):
     tests_impl(session)
 
diff --git a/pyproject.toml b/pyproject.toml
index 393e128..b1f2e5e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,8 @@ classifiers = [
   "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
+  "Programming Language :: Python :: 3.11",
+  "Programming Language :: Python :: 3.12",
   "Topic :: System :: Logging",
   "License :: OSI Approved :: Apache Software License"
 ]


Test patch:
diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml
index 74476b1..4f82483 100644
--- a/.github/workflows/test-docs.yml
+++ b/.github/workflows/test-docs.yml
@@ -19,7 +19,7 @@ jobs:
     timeout-minutes: 5
     strategy:
       matrix:
-        python: [ '3.7', '3.8', '3.9', '3.10' ]
+        python: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ]
       fail-fast: false
     steps:
     - run: 'echo "No build required"'
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d923427..4715352 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -41,7 +41,7 @@ jobs:
     timeout-minutes: 10
     strategy:
       matrix:
-        python: [ '3.7', '3.8', '3.9', '3.10' ]
+        python: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ]
       fail-fast: false
     steps:
     - uses: actions/checkout@v4
diff --git a/tests/test_apm.py b/tests/test_apm.py
index e9495a2..14622cc 100644
--- a/tests/test_apm.py
+++ b/tests/test_apm.py
@@ -49,7 +49,7 @@ def test_elasticapm_structlog_log_correlation_ecs_fields(spec_validator, apm):
     ecs = json.loads(spec_validator(stream.getvalue().rstrip()))
     ecs.pop("@timestamp")
     assert ecs == {
-        "ecs": {"version": "1.6.0"},
+        "ecs.version": "1.6.0",
         "log.level": "info",
         "message": "test message",
         "span": {"id": span_id},
@@ -84,7 +84,7 @@ def test_elastic_apm_stdlib_no_filter_log_correlation_ecs_fields(apm):
 
     ecs = json.loads(stream.getvalue().rstrip())
     assert ecs == {
-        "ecs": {"version": "1.6.0"},
+        "ecs.version": "1.6.0",
         "log.level": "info",
         "log": {
             "logger": "apm-logger",
@@ -128,7 +128,7 @@ def test_elastic_apm_stdlib_with_filter_log_correlation_ecs_fields(apm):
 
     ecs = json.loads(stream.getvalue().rstrip())
     assert ecs == {
-        "ecs": {"version": "1.6.0"},
+        "ecs.version": "1.6.0",
         "log.level": "info",
         "log": {
             "logger": "apm-logger",
@@ -175,7 +175,7 @@ def test_elastic_apm_stdlib_exclude_fields(apm):
 
     ecs = json.loads(stream.getvalue().rstrip())
     assert ecs == {
-        "ecs": {"version": "1.6.0"},
+        "ecs.version": "1.6.0",
         "log.level": "info",
         "log": {
             "logger": "apm-logger",
diff --git a/tests/test_stdlib_formatter.py b/tests/test_stdlib_formatter.py
index 62bc41b..e7b2b57 100644
--- a/tests/test_stdlib_formatter.py
+++ b/tests/test_stdlib_formatter.py
@@ -51,7 +51,7 @@ def test_record_formatted(spec_validator):
     formatter = ecs_logging.StdlibFormatter(exclude_fields=["process"])
 
     assert spec_validator(formatter.format(make_record())) == (
-        '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello","ecs":{"version":"1.6.0"},'
+        '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello","ecs.version":"1.6.0",'
         '"log":{"logger":"logger-name","origin":{"file":{"line":10,"name":"file.py"},"function":"test_function"},'
         '"original":"1: hello"}}'
     )
@@ -63,7 +63,7 @@ def test_extra_global_is_merged(spec_validator):
     )
 
     assert spec_validator(formatter.format(make_record())) == (
-        '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello","ecs":{"version":"1.6.0"},'
+        '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello","ecs.version":"1.6.0",'
         '"environment":"dev",'
         '"log":{"logger":"logger-name","origin":{"file":{"line":10,"name":"file.py"},"function":"test_function"},'
         '"original":"1: hello"}}'
@@ -80,7 +80,7 @@ def test_can_be_overridden(spec_validator):
     formatter = CustomFormatter(exclude_fields=["process"])
     assert spec_validator(formatter.format(make_record())) == (
         '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello",'
-        '"custom":"field","ecs":{"version":"1.6.0"},"log":{"logger":"logger-name","origin":'
+        '"custom":"field","ecs.version":"1.6.0","log":{"logger":"logger-name","origin":'
         '{"file":{"line":10,"name":"file.py"},"function":"test_function"},"original":"1: hello"}}'
     )
 
@@ -94,7 +94,7 @@ def test_can_be_set_on_handler():
 
     assert stream.getvalue() == (
         '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello",'
-        '"ecs":{"version":"1.6.0"},"log":{"logger":"logger-name","origin":{"file":{"line":10,'
+        '"ecs.version":"1.6.0","log":{"logger":"logger-name","origin":{"file":{"line":10,'
         '"name":"file.py"},"function":"test_function"},"original":"1: hello"}}\n'
     )
 
@@ -127,7 +127,7 @@ def test_extra_is_merged(time, logger):
     assert isinstance(ecs["log"]["origin"]["file"].pop("line"), int)
     assert ecs == {
         "@timestamp": "2020-03-20T16:16:37.187Z",
-        "ecs": {"version": "1.6.0"},
+        "ecs.version": "1.6.0",
         "log.level": "info",
         "log": {
             "logger": logger.name,
@@ -254,8 +254,6 @@ def test_stack_trace_limit_types_and_values():
     "exclude_fields",
     [
         "process",
-        "ecs",
-        "ecs.version",
         "log",
         "log.level",
         "message",
@@ -279,6 +277,19 @@ def test_exclude_fields(exclude_fields):
         assert field_path[-1] not in obj
 
 
+@pytest.mark.parametrize(
+    "exclude_fields",
+    [
+        "ecs.version",
+    ],
+)
+def test_exclude_fields_not_dedotted(exclude_fields):
+    formatter = ecs_logging.StdlibFormatter(exclude_fields=[exclude_fields])
+    ecs = formatter.format_to_ecs(make_record())
+    for entry in exclude_fields:
+        assert entry not in ecs
+
+
 def test_exclude_fields_empty_json_object():
     """Assert that if all JSON objects attributes are excluded then the object doesn't appear."""
     formatter = ecs_logging.StdlibFormatter(
@@ -350,7 +361,7 @@ def test_apm_data_conflicts(spec_validator):
     formatter = ecs_logging.StdlibFormatter(exclude_fields=["process"])
 
     assert spec_validator(formatter.format(record)) == (
-        '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello","ecs":{"version":"1.6.0"},'
+        '{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello","ecs.version":"1.6.0",'
         '"log":{"logger":"logger-name","origin":{"file":{"line":10,"name":"file.py"},"function":"test_function"},'
         '"original":"1: hello"},"service":{"environment":"dev","name":"myapp","version":"1.0.0"}}'
     )
diff --git a/tests/test_structlog_formatter.py b/tests/test_structlog_formatter.py
index 3338a4c..e9a4296 100644
--- a/tests/test_structlog_formatter.py
+++ b/tests/test_structlog_formatter.py
@@ -66,7 +66,7 @@ def test_event_dict_formatted(time, spec_validator, event_dict):
         '{"@timestamp":"2020-03-20T16:16:37.187Z","log.level":"debug",'
         '"message":"test message",'
         '"baz":"<NotSerializable>",'
-        '"ecs":{"version":"1.6.0"},'
+        '"ecs.version":"1.6.0",'
         '"foo":"bar",'
         '"log":{"logger":"logger-name"}}'
     )
@@ -90,7 +90,7 @@ def test_can_be_set_as_processor(time, spec_validator):
     assert spec_validator(stream.getvalue()) == (
         '{"@timestamp":"2020-03-20T16:16:37.187Z","log.level":"debug",'
         '"message":"test message","custom":"key","dot":{"ted":1},'
-        '"ecs":{"version":"1.6.0"}}\n'
+        '"ecs.version":"1.6.0"}\n'
     )