Skip to content

Commit 39b04a9

Browse files
authored
Merge pull request #31 from basepi/specvalidation
Validate against spec
2 parents a1198f3 + e3986bb commit 39b04a9

File tree

6 files changed

+222
-10
lines changed

6 files changed

+222
-10
lines changed

.flake8

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[flake8]
2+
exclude=
3+
tests/**,
4+
conftest.py,
5+
setup.py
6+
max-line-length=120
7+
ignore=E731,W503,E203,BLK100,B301

tests/conftest.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import datetime
2+
import json
3+
import os
4+
import collections
5+
6+
import pytest
7+
8+
9+
class ValidationError(Exception):
10+
pass
11+
12+
13+
@pytest.fixture
14+
def spec_validator():
15+
with open(
16+
os.path.join(os.path.dirname(__file__), "resources", "spec.json"), "r"
17+
) as fh:
18+
spec = json.load(fh)
19+
20+
def validator(data_json):
21+
"""
22+
Throws a ValidationError if anything doesn't match the spec.
23+
24+
Returns the original json (pass-through)
25+
"""
26+
fields = spec["fields"]
27+
data = json.loads(data_json, object_pairs_hook=collections.OrderedDict)
28+
for k, v in fields.items():
29+
if v.get("required"):
30+
found = False
31+
if k in data:
32+
found = True
33+
elif "." in k:
34+
# Dotted keys could be nested, like ecs.version
35+
subkeys = k.split(".")
36+
subval = data
37+
for subkey in subkeys:
38+
subval = subval.get(subkey, {})
39+
if subval:
40+
found = True
41+
if not found:
42+
raise ValidationError("Missing required key {}".format(k))
43+
if k in data:
44+
if v["type"] == "string" and not (
45+
isinstance(data[k], str) or isinstance(data[k], basestring)
46+
):
47+
raise ValidationError(
48+
"Value {0} for key {1} should be string, is {2}".format(
49+
data[k], k, type(data[k])
50+
)
51+
)
52+
if v["type"] == "datetime":
53+
try:
54+
datetime.datetime.strptime(data[k], "%Y-%m-%dT%H:%M:%S.%fZ")
55+
except ValueError:
56+
raise ValidationError(
57+
"Value {0} for key {1} doesn't parse as an ISO datetime".format(
58+
data[k], k
59+
)
60+
)
61+
if v.get("index") and list(data.keys())[v.get("index")] != k:
62+
raise ValidationError("Key {0} is not at index {1}".format(k, index))
63+
64+
return data_json
65+
66+
return validator

tests/resources/spec.json

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
{
2+
"version": 1.0,
3+
"url": "https://www.elastic.co/guide/en/ecs/current/index.html",
4+
"ecs": {
5+
"version": "1.x"
6+
},
7+
"fields": {
8+
"@timestamp": {
9+
"type": "datetime",
10+
"required": true,
11+
"index": 0,
12+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-base.html"
13+
},
14+
"log.level": {
15+
"type": "string",
16+
"required": true,
17+
"index": 1,
18+
"top_level_field": true,
19+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-log.html",
20+
"comment": [
21+
"This field SHOULD NOT be a nested object field but at the top level with a dot in the property name.",
22+
"This is to make the JSON logs more human-readable.",
23+
"Loggers MAY indent the log level so that the `message` field always starts at the exact same offset,",
24+
"no matter the number of characters the log level has.",
25+
"For example: `'DEBUG'` (5 chars) will not be indented, whereas ` 'WARN'` (4 chars) will be indented by one space character."
26+
]
27+
},
28+
"message": {
29+
"type": "string",
30+
"required": true,
31+
"index": 2,
32+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-base.html"
33+
},
34+
"ecs.version": {
35+
"type": "string",
36+
"required": true,
37+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-ecs.html"
38+
},
39+
"labels": {
40+
"type": "object",
41+
"required": false,
42+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-base.html",
43+
"sanitization": {
44+
"key": {
45+
"replacements": [
46+
".",
47+
"*",
48+
"\\"
49+
],
50+
"substitute": "_"
51+
}
52+
}
53+
},
54+
"trace.id": {
55+
"type": "string",
56+
"required": false,
57+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html",
58+
"comment": "When APM agents add this field to the context, ecs loggers should pick it up and add it to the log event."
59+
},
60+
"transaction.id": {
61+
"type": "string",
62+
"required": false,
63+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-tracing.html",
64+
"comment": "When APM agents add this field to the context, ecs loggers should pick it up and add it to the log event."
65+
},
66+
"service.name": {
67+
"type": "string",
68+
"required": false,
69+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-service.html",
70+
"comment": [
71+
"Configurable by users.",
72+
"When an APM agent is active, they should auto-configure it if not already set."
73+
]
74+
},
75+
"event.dataset": {
76+
"type": "string",
77+
"required": false,
78+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-event.html",
79+
"default": "${service.name}.log OR ${service.name}.${appender.name}",
80+
"comment": [
81+
"Configurable by users.",
82+
"If the user manually configures the service name,",
83+
"the logging library should set `event.dataset=${service.name}.log` if not explicitly configured otherwise.",
84+
"",
85+
"When agents auto-configure the app to use an ECS logger,",
86+
"they should set `event.dataset=${service.name}.${appender.name}` if the appender name is available in the logging library.",
87+
"Otherwise, agents should also set `event.dataset=${service.name}.log`",
88+
"",
89+
"The field helps to filter for different log streams from the same pod, for example and is required for log anomaly detection."
90+
]
91+
},
92+
"process.thread.name": {
93+
"type": "string",
94+
"required": false,
95+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-process.html"
96+
},
97+
"log.logger": {
98+
"type": "string",
99+
"required": false,
100+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-log.html"
101+
},
102+
"log.origin.file.line": {
103+
"type": "integer",
104+
"required": false,
105+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-log.html",
106+
"comment": "Should be opt-in as it requires the logging library to capture a stack trace for each log event."
107+
},
108+
"log.origin.file.name": {
109+
"type": "string",
110+
"required": false,
111+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-log.html",
112+
"comment": "Should be opt-in as it requires the logging library to capture a stack trace for each log event."
113+
},
114+
"log.origin.function": {
115+
"type": "string",
116+
"required": false,
117+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-log.html",
118+
"comment": "Should be opt-in as it requires the logging library to capture a stack trace for each log event."
119+
},
120+
"error.type": {
121+
"type": "string",
122+
"required": false,
123+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-error.html",
124+
"comment": "The exception type or class, such as `java.lang.IllegalArgumentException`."
125+
},
126+
"error.message": {
127+
"type": "string",
128+
"required": false,
129+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-error.html",
130+
"comment": "The message of the exception."
131+
},
132+
"error.stack_trace": {
133+
"type": "string",
134+
"required": false,
135+
"url": "https://www.elastic.co/guide/en/ecs/current/ecs-error.html",
136+
"comment": "The stack trace of the exception as plain text."
137+
}
138+
}
139+
}

tests/test_apm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .compat import StringIO
1111

1212

13-
def test_elasticapm_structlog_log_correlation_ecs_fields():
13+
def test_elasticapm_structlog_log_correlation_ecs_fields(spec_validator):
1414
apm = elasticapm.Client({"SERVICE_NAME": "apm-service", "DISABLE_SEND": True})
1515
stream = StringIO()
1616
logger = structlog.PrintLogger(stream)
@@ -30,7 +30,7 @@ def test_elasticapm_structlog_log_correlation_ecs_fields():
3030
finally:
3131
apm.end_transaction("test-transaction")
3232

33-
ecs = json.loads(stream.getvalue().rstrip())
33+
ecs = json.loads(spec_validator(stream.getvalue().rstrip()))
3434
ecs.pop("@timestamp")
3535
assert ecs == {
3636
"ecs": {"version": "1.6.0"},

tests/test_stdlib_formatter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,25 +35,25 @@ def make_record():
3535
return record
3636

3737

38-
def test_record_formatted():
38+
def test_record_formatted(spec_validator):
3939
formatter = ecs_logging.StdlibFormatter(exclude_fields=["process"])
4040

41-
assert formatter.format(make_record()) == (
41+
assert spec_validator(formatter.format(make_record())) == (
4242
'{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello","ecs":{"version":"1.6.0"},'
4343
'"log":{"logger":"logger-name","origin":{"file":{"line":10,"name":"file.py"},"function":"test_function"},'
4444
'"original":"1: hello"}}'
4545
)
4646

4747

48-
def test_can_be_overridden():
48+
def test_can_be_overridden(spec_validator):
4949
class CustomFormatter(ecs_logging.StdlibFormatter):
5050
def format_to_ecs(self, record):
5151
ecs_dict = super(CustomFormatter, self).format_to_ecs(record)
5252
ecs_dict["custom"] = "field"
5353
return ecs_dict
5454

5555
formatter = CustomFormatter(exclude_fields=["process"])
56-
assert formatter.format(make_record()) == (
56+
assert spec_validator(formatter.format(make_record())) == (
5757
'{"@timestamp":"2020-03-20T14:12:46.123Z","log.level":"debug","message":"1: hello",'
5858
'"custom":"field","ecs":{"version":"1.6.0"},"log":{"logger":"logger-name","origin":'
5959
'{"file":{"line":10,"name":"file.py"},"function":"test_function"},"original":"1: hello"}}'

tests/test_structlog_formatter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@ def make_event_dict():
99

1010

1111
@mock.patch("time.time")
12-
def test_event_dict_formatted(time):
12+
def test_event_dict_formatted(time, spec_validator):
1313
time.return_value = 1584720997.187709
1414

1515
formatter = ecs_logging.StructlogFormatter()
16-
assert formatter(None, "debug", make_event_dict()) == (
16+
assert spec_validator(formatter(None, "debug", make_event_dict())) == (
1717
'{"@timestamp":"2020-03-20T16:16:37.187Z","log.level":"debug",'
1818
'"message":"test message","ecs":{"version":"1.6.0"},'
1919
'"log":{"logger":"logger-name"}}'
2020
)
2121

2222

2323
@mock.patch("time.time")
24-
def test_can_be_set_as_processor(time):
24+
def test_can_be_set_as_processor(time, spec_validator):
2525
time.return_value = 1584720997.187709
2626

2727
stream = StringIO()
@@ -35,7 +35,7 @@ def test_can_be_set_as_processor(time):
3535
logger = structlog.get_logger("logger-name")
3636
logger.debug("test message", custom="key", **{"dot.ted": 1})
3737

38-
assert stream.getvalue() == (
38+
assert spec_validator(stream.getvalue()) == (
3939
'{"@timestamp":"2020-03-20T16:16:37.187Z","log.level":"debug",'
4040
'"message":"test message","custom":"key","dot":{"ted":1},'
4141
'"ecs":{"version":"1.6.0"}}\n'

0 commit comments

Comments
 (0)