kinro-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kinro_sdk-0.1.0/PKG-INFO +25 -0
- kinro_sdk-0.1.0/README.md +18 -0
- kinro_sdk-0.1.0/pyproject.toml +15 -0
- kinro_sdk-0.1.0/src/kinro/contract/__init__.py +41 -0
- kinro_sdk-0.1.0/src/kinro/contract/emit.py +151 -0
- kinro_sdk-0.1.0/src/kinro/contract/model.py +324 -0
- kinro_sdk-0.1.0/src/kinro/contract/py.typed +0 -0
- kinro_sdk-0.1.0/src/kinro/contract/schema.json +25 -0
- kinro_sdk-0.1.0/src/kinro/parsers/__init__.py +17 -0
- kinro_sdk-0.1.0/src/kinro/parsers/base.py +96 -0
- kinro_sdk-0.1.0/src/kinro/parsers/contract.py +29 -0
- kinro_sdk-0.1.0/src/kinro/parsers/gelf.py +135 -0
- kinro_sdk-0.1.0/src/kinro/parsers/py.typed +0 -0
- kinro_sdk-0.1.0/src/kinro/parsers/regex.py +78 -0
- kinro_sdk-0.1.0/src/kinro/parsers/template_json.py +63 -0
- kinro_sdk-0.1.0/src/kinro/sdk/__init__.py +84 -0
- kinro_sdk-0.1.0/src/kinro/sdk/builtins.py +323 -0
- kinro_sdk-0.1.0/src/kinro/sdk/keys.py +71 -0
- kinro_sdk-0.1.0/src/kinro/sdk/project.py +497 -0
- kinro_sdk-0.1.0/src/kinro/sdk/py.typed +0 -0
- kinro_sdk-0.1.0/src/kinro/sdk/rule.py +280 -0
- kinro_sdk-0.1.0/src/kinro/sdk/sources.py +173 -0
- kinro_sdk-0.1.0/src/kinro/sdk/testing.py +12 -0
- kinro_sdk-0.1.0/src/kinro/sdk/timeline.py +52 -0
kinro_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: kinro-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Write Kinro project files, rules and parsers; emit the Kinro log contract
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# kinro-sdk
|
|
9
|
+
|
|
10
|
+
Everything needed to write a Kinro project file, rules and custom parsers, with type hints for the IDE:
|
|
11
|
+
|
|
12
|
+
- `kinro.sdk`: `Project`, `Rule`, `RuleContext`, `on`, `Query`, `Lookup`, `Violation`, `Field`, `CID`, built-in rules,
|
|
13
|
+
source configs (`LogSource`, `LokiLogs`/`LokiConfig`, `GraylogLogs`/`GraylogConfig`) and the parsers.
|
|
14
|
+
- `kinro.parsers`: the `Parser` base and the built-in parsers.
|
|
15
|
+
- `kinro.contract`: the log contract model, `ContractFormatter`, `GelfContractFilter`, `start_heartbeat` and
|
|
16
|
+
`schema.json`, for services that emit contract logs.
|
|
17
|
+
|
|
18
|
+
Standard library only. The engine (sources, state, alerts, CLI, `kinro.testing.RuleHarness`) ships separately as
|
|
19
|
+
the `kinro` package.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install kinro_sdk-0.1.0-py3-none-any.whl
|
|
23
|
+
pip install 'kinro-sdk @ git+https://<host>/<org>/kinro.git#subdirectory=packages/kinro-sdk'
|
|
24
|
+
pip install --index-url https://<private-index>/simple kinro-sdk
|
|
25
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# kinro-sdk
|
|
2
|
+
|
|
3
|
+
Everything needed to write a Kinro project file, rules and custom parsers, with type hints for the IDE:
|
|
4
|
+
|
|
5
|
+
- `kinro.sdk`: `Project`, `Rule`, `RuleContext`, `on`, `Query`, `Lookup`, `Violation`, `Field`, `CID`, built-in rules,
|
|
6
|
+
source configs (`LogSource`, `LokiLogs`/`LokiConfig`, `GraylogLogs`/`GraylogConfig`) and the parsers.
|
|
7
|
+
- `kinro.parsers`: the `Parser` base and the built-in parsers.
|
|
8
|
+
- `kinro.contract`: the log contract model, `ContractFormatter`, `GelfContractFilter`, `start_heartbeat` and
|
|
9
|
+
`schema.json`, for services that emit contract logs.
|
|
10
|
+
|
|
11
|
+
Standard library only. The engine (sources, state, alerts, CLI, `kinro.testing.RuleHarness`) ships separately as
|
|
12
|
+
the `kinro` package.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install kinro_sdk-0.1.0-py3-none-any.whl
|
|
16
|
+
pip install 'kinro-sdk @ git+https://<host>/<org>/kinro.git#subdirectory=packages/kinro-sdk'
|
|
17
|
+
pip install --index-url https://<private-index>/simple kinro-sdk
|
|
18
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = 'kinro-sdk'
|
|
3
|
+
version = '0.1.0'
|
|
4
|
+
description = 'Write Kinro project files, rules and parsers; emit the Kinro log contract'
|
|
5
|
+
readme = 'README.md'
|
|
6
|
+
requires-python = '>=3.13'
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ['uv_build>=0.11,<0.12']
|
|
11
|
+
build-backend = 'uv_build'
|
|
12
|
+
|
|
13
|
+
[tool.uv.build-backend]
|
|
14
|
+
module-name = 'kinro'
|
|
15
|
+
namespace = true
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from kinro.contract.emit import HEARTBEAT_INTERVAL, ContractFormatter, GelfContractFilter, Heartbeat, start_heartbeat
|
|
2
|
+
from kinro.contract.model import (
|
|
3
|
+
HEARTBEAT_EVENT,
|
|
4
|
+
MAX_LINE_BYTES,
|
|
5
|
+
MAX_PAYLOAD_BYTES,
|
|
6
|
+
SCHEMA_VERSION,
|
|
7
|
+
ContractError,
|
|
8
|
+
DbObsId,
|
|
9
|
+
FileObsId,
|
|
10
|
+
GraylogObsId,
|
|
11
|
+
JsonValue,
|
|
12
|
+
LogContractV1,
|
|
13
|
+
LokiObsId,
|
|
14
|
+
Observation,
|
|
15
|
+
ObsId,
|
|
16
|
+
TsSource,
|
|
17
|
+
obs_id_from_key,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
'HEARTBEAT_EVENT',
|
|
22
|
+
'HEARTBEAT_INTERVAL',
|
|
23
|
+
'MAX_LINE_BYTES',
|
|
24
|
+
'MAX_PAYLOAD_BYTES',
|
|
25
|
+
'SCHEMA_VERSION',
|
|
26
|
+
'ContractError',
|
|
27
|
+
'ContractFormatter',
|
|
28
|
+
'DbObsId',
|
|
29
|
+
'FileObsId',
|
|
30
|
+
'GelfContractFilter',
|
|
31
|
+
'GraylogObsId',
|
|
32
|
+
'Heartbeat',
|
|
33
|
+
'JsonValue',
|
|
34
|
+
'LogContractV1',
|
|
35
|
+
'LokiObsId',
|
|
36
|
+
'ObsId',
|
|
37
|
+
'Observation',
|
|
38
|
+
'TsSource',
|
|
39
|
+
'obs_id_from_key',
|
|
40
|
+
'start_heartbeat',
|
|
41
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from kinro.contract.model import HEARTBEAT_EVENT, JsonValue, LogContractV1
|
|
11
|
+
|
|
12
|
+
HEARTBEAT_INTERVAL = timedelta(seconds=2)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContractFormatter(logging.Formatter):
|
|
16
|
+
"""Records with `extra={'event': ...}` become LogContract v1 lines; others keep the template JsonFormatter shape.
|
|
17
|
+
|
|
18
|
+
Give every handler its own instance: each one numbers its lines, so a shared instance leaves `seq` gaps.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, service: str) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.service = service
|
|
24
|
+
self._sequence = _Sequence()
|
|
25
|
+
|
|
26
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
27
|
+
event = getattr(record, 'event', None)
|
|
28
|
+
if not isinstance(event, str) or not event:
|
|
29
|
+
return self._plain(record)
|
|
30
|
+
contract = LogContractV1(
|
|
31
|
+
ts=datetime.fromtimestamp(record.created, tz=UTC),
|
|
32
|
+
svc=self.service,
|
|
33
|
+
event=event,
|
|
34
|
+
level=record.levelname,
|
|
35
|
+
cid=_as_text(getattr(record, 'cid', None)),
|
|
36
|
+
keys=_as_keys(getattr(record, 'keys', None)),
|
|
37
|
+
data=_as_data(getattr(record, 'data', None)),
|
|
38
|
+
msg=record.getMessage(),
|
|
39
|
+
exc=self.formatException(record.exc_info) if record.exc_info else None,
|
|
40
|
+
)
|
|
41
|
+
inst, seq = self._sequence.next()
|
|
42
|
+
contract = replace(contract, inst=inst, seq=seq)
|
|
43
|
+
# Once seq is taken the line must be emitted, otherwise Kinro sees a gap that never happened.
|
|
44
|
+
try:
|
|
45
|
+
return json.dumps(contract.to_dict(), ensure_ascii=False, default=str)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return json.dumps(replace(contract, data={}).to_dict(), ensure_ascii=False, default=str)
|
|
48
|
+
|
|
49
|
+
def _plain(self, record: logging.LogRecord) -> str:
|
|
50
|
+
payload = {
|
|
51
|
+
'time': datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
|
|
52
|
+
'level': record.levelname,
|
|
53
|
+
'logger': record.name,
|
|
54
|
+
'message': record.getMessage(),
|
|
55
|
+
}
|
|
56
|
+
if record.exc_info:
|
|
57
|
+
payload['exc_info'] = self.formatException(record.exc_info)
|
|
58
|
+
return json.dumps(payload, ensure_ascii=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GelfContractFilter(logging.Filter):
|
|
62
|
+
"""Stamps `inst` and `seq` on records with `extra={'event': ...}` for a GELF handler such as graypy's,
|
|
63
|
+
which sends record attributes as additional fields and never calls a formatter for them.
|
|
64
|
+
|
|
65
|
+
Add one instance to each GELF handler (`handler.addFilter(GelfContractFilter())`); like ContractFormatter,
|
|
66
|
+
a shared instance leaves `seq` gaps in each output.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self._sequence = _Sequence()
|
|
72
|
+
|
|
73
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
74
|
+
event = getattr(record, 'event', None)
|
|
75
|
+
if isinstance(event, str) and event:
|
|
76
|
+
record.inst, record.seq = self._sequence.next()
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Sequence:
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
self._pid = -1
|
|
84
|
+
self._inst = ''
|
|
85
|
+
self._next_seq = 0
|
|
86
|
+
|
|
87
|
+
def next(self) -> tuple[str, int]:
|
|
88
|
+
with self._lock:
|
|
89
|
+
pid = os.getpid()
|
|
90
|
+
if pid != self._pid:
|
|
91
|
+
# A forked child inherits the parent's counter; it starts its own sequence under its own inst.
|
|
92
|
+
self._pid, self._inst, self._next_seq = pid, f'{socket.gethostname()}:{pid}', 0
|
|
93
|
+
seq = self._next_seq
|
|
94
|
+
self._next_seq += 1
|
|
95
|
+
return self._inst, seq
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Heartbeat:
|
|
99
|
+
def __init__(self, logger: logging.Logger, every: timedelta) -> None:
|
|
100
|
+
self._logger = logger
|
|
101
|
+
self._every = every.total_seconds()
|
|
102
|
+
self._stopped = threading.Event()
|
|
103
|
+
self._thread = threading.Thread(target=self._run, name='kinro-heartbeat', daemon=True)
|
|
104
|
+
|
|
105
|
+
def start(self) -> None:
|
|
106
|
+
self._thread.start()
|
|
107
|
+
|
|
108
|
+
def stop(self) -> None:
|
|
109
|
+
self._stopped.set()
|
|
110
|
+
self._thread.join()
|
|
111
|
+
|
|
112
|
+
def _run(self) -> None:
|
|
113
|
+
while True:
|
|
114
|
+
self._beat()
|
|
115
|
+
if self._stopped.wait(self._every):
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
def _beat(self) -> None:
|
|
119
|
+
# Bypasses the logger level, so LOG_LEVEL=WARNING cannot silence the producer watermark.
|
|
120
|
+
record = self._logger.makeRecord(
|
|
121
|
+
self._logger.name, logging.INFO, __file__, 0, 'kinro heartbeat', (), None,
|
|
122
|
+
extra={'event': HEARTBEAT_EVENT},
|
|
123
|
+
)
|
|
124
|
+
self._logger.handle(record)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def start_heartbeat(logger: logging.Logger, every: timedelta = HEARTBEAT_INTERVAL) -> Heartbeat:
|
|
128
|
+
"""Emit a `kinro.heartbeat` record through `logger` now and then every `every` from a daemon thread.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
logger: Must reach the handler carrying ContractFormatter, e.g. the root logger.
|
|
132
|
+
"""
|
|
133
|
+
heartbeat = Heartbeat(logger, every)
|
|
134
|
+
heartbeat.start()
|
|
135
|
+
return heartbeat
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _as_text(value: object) -> str | None:
|
|
139
|
+
return None if value is None else str(value)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _as_keys(value: object) -> dict[str, str]:
|
|
143
|
+
if not isinstance(value, Mapping):
|
|
144
|
+
return {}
|
|
145
|
+
return {str(name): str(key) for name, key in value.items() if key is not None}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _as_data(value: object) -> dict[str, JsonValue]:
|
|
149
|
+
if not isinstance(value, Mapping):
|
|
150
|
+
return {}
|
|
151
|
+
return {str(name): item for name, item in value.items()}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Collection, Mapping
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any, ClassVar, Literal, Self
|
|
6
|
+
|
|
7
|
+
SCHEMA_VERSION = 1
|
|
8
|
+
HEARTBEAT_EVENT = 'kinro.heartbeat'
|
|
9
|
+
MAX_LINE_BYTES = 1024 * 1024
|
|
10
|
+
MAX_PAYLOAD_BYTES = 2 * 1024
|
|
11
|
+
REDACTED = '[redacted]'
|
|
12
|
+
|
|
13
|
+
type JsonValue = str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]
|
|
14
|
+
type TsSource = Literal['body', 'push', 'fetched']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContractError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class FileObsId:
|
|
23
|
+
KIND: ClassVar[str] = 'file'
|
|
24
|
+
|
|
25
|
+
source_id: str
|
|
26
|
+
file_uid: int
|
|
27
|
+
generation: int
|
|
28
|
+
byte_offset: int
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def key(self) -> str:
|
|
32
|
+
return _dump_key([self.KIND, self.source_id, self.file_uid, self.generation, self.byte_offset])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class LokiObsId:
|
|
37
|
+
KIND: ClassVar[str] = 'loki'
|
|
38
|
+
|
|
39
|
+
source_id: str
|
|
40
|
+
stream_labels_hash: str
|
|
41
|
+
ts_ns: int
|
|
42
|
+
line_hash: str
|
|
43
|
+
dup_index: int
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def key(self) -> str:
|
|
47
|
+
return _dump_key([self.KIND, self.source_id, self.stream_labels_hash, self.ts_ns, self.line_hash,
|
|
48
|
+
self.dup_index])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class DbObsId:
|
|
53
|
+
KIND: ClassVar[str] = 'db'
|
|
54
|
+
|
|
55
|
+
source_id: str
|
|
56
|
+
query_name: str
|
|
57
|
+
poll_seq: int
|
|
58
|
+
row_index: int
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def key(self) -> str:
|
|
62
|
+
return _dump_key([self.KIND, self.source_id, self.query_name, self.poll_seq, self.row_index])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class GraylogObsId:
|
|
67
|
+
"""`message_id` is Graylog's `gl2_message_id` (`_id` when absent); it stays the same across index rotation."""
|
|
68
|
+
|
|
69
|
+
KIND: ClassVar[str] = 'graylog'
|
|
70
|
+
|
|
71
|
+
source_id: str
|
|
72
|
+
ts_ms: int
|
|
73
|
+
message_id: str
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def key(self) -> str:
|
|
77
|
+
return _dump_key([self.KIND, self.source_id, self.ts_ms, self.message_id])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
type ObsId = FileObsId | LokiObsId | DbObsId | GraylogObsId
|
|
81
|
+
|
|
82
|
+
_OBS_ID_TYPES: dict[str, type[FileObsId] | type[LokiObsId] | type[DbObsId] | type[GraylogObsId]] = {
|
|
83
|
+
kind.KIND: kind for kind in (FileObsId, LokiObsId, DbObsId, GraylogObsId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def obs_id_from_key(key: str) -> ObsId:
|
|
88
|
+
kind, *parts = json.loads(key)
|
|
89
|
+
return _OBS_ID_TYPES[kind](*parts)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
93
|
+
class LogContractV1:
|
|
94
|
+
ts: datetime
|
|
95
|
+
svc: str
|
|
96
|
+
event: str
|
|
97
|
+
level: str | None = None
|
|
98
|
+
cid: str | None = None
|
|
99
|
+
keys: Mapping[str, str] = field(default_factory=dict)
|
|
100
|
+
data: Mapping[str, JsonValue] = field(default_factory=dict)
|
|
101
|
+
msg: str | None = None
|
|
102
|
+
exc: str | None = None
|
|
103
|
+
inst: str | None = None
|
|
104
|
+
seq: int | None = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def is_heartbeat(self) -> bool:
|
|
108
|
+
return self.event == HEARTBEAT_EVENT
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
111
|
+
line: dict[str, JsonValue] = {
|
|
112
|
+
'kinro_v': SCHEMA_VERSION, 'ts': self.ts.isoformat(), 'svc': self.svc, 'event': self.event,
|
|
113
|
+
}
|
|
114
|
+
optional: dict[str, JsonValue] = {
|
|
115
|
+
'level': self.level,
|
|
116
|
+
'cid': self.cid,
|
|
117
|
+
'keys': dict(self.keys) or None,
|
|
118
|
+
'data': dict(self.data) or None,
|
|
119
|
+
'msg': self.msg,
|
|
120
|
+
'exc': self.exc,
|
|
121
|
+
'inst': self.inst,
|
|
122
|
+
'seq': self.seq,
|
|
123
|
+
}
|
|
124
|
+
line.update((name, value) for name, value in optional.items() if value is not None)
|
|
125
|
+
return line
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_dict(cls, raw: Mapping[str, object]) -> Self:
|
|
129
|
+
version = raw.get('kinro_v')
|
|
130
|
+
if type(version) is not int or version != SCHEMA_VERSION:
|
|
131
|
+
raise ContractError(f'unsupported kinro_v {version!r}')
|
|
132
|
+
seq = raw.get('seq')
|
|
133
|
+
if seq is not None and (type(seq) is not int or seq < 0):
|
|
134
|
+
raise ContractError(f'seq must be a non-negative int, got {seq!r}')
|
|
135
|
+
return cls(
|
|
136
|
+
ts=parse_aware_ts(raw.get('ts')),
|
|
137
|
+
svc=_required_text(raw, 'svc'),
|
|
138
|
+
event=_required_text(raw, 'event'),
|
|
139
|
+
level=_optional_text(raw, 'level'),
|
|
140
|
+
cid=_optional_text(raw, 'cid'),
|
|
141
|
+
keys=_keys(raw.get('keys')),
|
|
142
|
+
data=_data(raw.get('data')),
|
|
143
|
+
msg=_optional_text(raw, 'msg', allow_empty=True),
|
|
144
|
+
exc=_optional_text(raw, 'exc', allow_empty=True),
|
|
145
|
+
inst=_optional_text(raw, 'inst'),
|
|
146
|
+
seq=seq,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
151
|
+
class Observation:
|
|
152
|
+
obs_id: ObsId
|
|
153
|
+
service: str
|
|
154
|
+
event: str
|
|
155
|
+
ts: datetime
|
|
156
|
+
ts_source: TsSource = 'body'
|
|
157
|
+
cid: str | None = None
|
|
158
|
+
keys: Mapping[str, str] = field(default_factory=dict)
|
|
159
|
+
payload: Mapping[str, JsonValue] = field(default_factory=dict)
|
|
160
|
+
level: str | None = None
|
|
161
|
+
inst: str | None = None
|
|
162
|
+
seq: int | None = None
|
|
163
|
+
truncated: bool = False
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def create(
|
|
167
|
+
cls,
|
|
168
|
+
*,
|
|
169
|
+
obs_id: ObsId,
|
|
170
|
+
service: str,
|
|
171
|
+
event: str,
|
|
172
|
+
ts: datetime,
|
|
173
|
+
ts_source: TsSource = 'body',
|
|
174
|
+
cid: str | None = None,
|
|
175
|
+
keys: Mapping[str, str] | None = None,
|
|
176
|
+
payload: Mapping[str, JsonValue] | None = None,
|
|
177
|
+
level: str | None = None,
|
|
178
|
+
inst: str | None = None,
|
|
179
|
+
seq: int | None = None,
|
|
180
|
+
payload_cap: int = MAX_PAYLOAD_BYTES,
|
|
181
|
+
) -> Self:
|
|
182
|
+
"""Build an observation with ts in UTC and payload fields dropped largest-first to fit `payload_cap`.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
payload_cap: Byte budget for the serialized observation; identity fields are never dropped to meet it.
|
|
186
|
+
"""
|
|
187
|
+
if ts.tzinfo is None:
|
|
188
|
+
raise ValueError('observation ts must carry a timezone')
|
|
189
|
+
ts = ts.astimezone(UTC)
|
|
190
|
+
keys = dict(keys or {})
|
|
191
|
+
fixed: dict[str, JsonValue] = {
|
|
192
|
+
'svc': service, 'event': event, 'ts': ts.isoformat(), 'cid': cid,
|
|
193
|
+
'keys': dict(keys),
|
|
194
|
+
}
|
|
195
|
+
capped, truncated = cap_payload(fixed, payload or {}, payload_cap)
|
|
196
|
+
return cls(
|
|
197
|
+
obs_id=obs_id, service=service, event=event, ts=ts, ts_source=ts_source, cid=cid, keys=keys,
|
|
198
|
+
payload=capped, level=level, inst=inst, seq=seq, truncated=truncated,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def is_heartbeat(self) -> bool:
|
|
203
|
+
return self.event == HEARTBEAT_EVENT
|
|
204
|
+
|
|
205
|
+
def redact(self, fields: Collection[str]) -> Self:
|
|
206
|
+
"""A copy whose top-level payload `fields` read `REDACTED`; nested values under such a name go too."""
|
|
207
|
+
if not any(name in self.payload for name in fields):
|
|
208
|
+
return self
|
|
209
|
+
payload = {name: REDACTED if name in fields else value for name, value in self.payload.items()}
|
|
210
|
+
return replace(self, payload=payload)
|
|
211
|
+
|
|
212
|
+
def lookup(self, name: str) -> JsonValue:
|
|
213
|
+
if name == 'cid':
|
|
214
|
+
return self.cid
|
|
215
|
+
if name in self.keys:
|
|
216
|
+
return self.keys[name]
|
|
217
|
+
return self.payload.get(name)
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> dict[str, JsonValue]:
|
|
220
|
+
return {
|
|
221
|
+
'obs_id': self.obs_id.key,
|
|
222
|
+
'service': self.service,
|
|
223
|
+
'event': self.event,
|
|
224
|
+
'ts': self.ts.isoformat(),
|
|
225
|
+
'ts_source': self.ts_source,
|
|
226
|
+
'cid': self.cid,
|
|
227
|
+
'keys': dict(self.keys),
|
|
228
|
+
'payload': dict(self.payload),
|
|
229
|
+
'level': self.level,
|
|
230
|
+
'inst': self.inst,
|
|
231
|
+
'seq': self.seq,
|
|
232
|
+
'truncated': self.truncated,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def from_dict(cls, raw: Mapping[str, Any]) -> Self:
|
|
237
|
+
return cls(
|
|
238
|
+
obs_id=obs_id_from_key(raw['obs_id']),
|
|
239
|
+
service=raw['service'],
|
|
240
|
+
event=raw['event'],
|
|
241
|
+
ts=datetime.fromisoformat(raw['ts']),
|
|
242
|
+
ts_source=raw['ts_source'],
|
|
243
|
+
cid=raw['cid'],
|
|
244
|
+
keys=raw['keys'],
|
|
245
|
+
payload=raw['payload'],
|
|
246
|
+
level=raw['level'],
|
|
247
|
+
inst=raw['inst'],
|
|
248
|
+
seq=raw['seq'],
|
|
249
|
+
truncated=raw['truncated'],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_aware_ts(value: object) -> datetime:
|
|
254
|
+
if not isinstance(value, str):
|
|
255
|
+
raise ContractError(f'ts must be an RFC 3339 string, got {value!r}')
|
|
256
|
+
try:
|
|
257
|
+
ts = datetime.fromisoformat(value)
|
|
258
|
+
except ValueError as exc:
|
|
259
|
+
raise ContractError(f'invalid ts {value!r}') from exc
|
|
260
|
+
if ts.tzinfo is None:
|
|
261
|
+
raise ContractError(f'ts {value!r} lacks a UTC offset')
|
|
262
|
+
return ts
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cap_payload(
|
|
266
|
+
fixed: Mapping[str, JsonValue], payload: Mapping[str, JsonValue], limit: int,
|
|
267
|
+
) -> tuple[dict[str, JsonValue], bool]:
|
|
268
|
+
kept = dict(payload)
|
|
269
|
+
total = _json_size({**fixed, 'data': kept})
|
|
270
|
+
if total <= limit:
|
|
271
|
+
return kept, False
|
|
272
|
+
# Size of `"name":value,` inside the data object.
|
|
273
|
+
sizes = sorted(((_json_size({name: value}) - 1, name) for name, value in kept.items()), reverse=True)
|
|
274
|
+
for size, name in sizes:
|
|
275
|
+
if total <= limit:
|
|
276
|
+
break
|
|
277
|
+
del kept[name]
|
|
278
|
+
total -= size
|
|
279
|
+
return kept, len(kept) < len(payload)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _json_size(value: JsonValue) -> int:
|
|
283
|
+
return len(json.dumps(value, ensure_ascii=False, separators=(',', ':'), default=str).encode())
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _dump_key(parts: list[JsonValue]) -> str:
|
|
287
|
+
return json.dumps(parts, ensure_ascii=False, separators=(',', ':'))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _required_text(raw: Mapping[str, object], name: str) -> str:
|
|
291
|
+
value = raw.get(name)
|
|
292
|
+
if not isinstance(value, str) or not value:
|
|
293
|
+
raise ContractError(f'{name} must be a non-empty string, got {value!r}')
|
|
294
|
+
return value
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _optional_text(raw: Mapping[str, object], name: str, *, allow_empty: bool = False) -> str | None:
|
|
298
|
+
value = raw.get(name)
|
|
299
|
+
if value is None:
|
|
300
|
+
return None
|
|
301
|
+
if not isinstance(value, str) or not (value or allow_empty):
|
|
302
|
+
raise ContractError(f'{name} must be a string, got {value!r}')
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _keys(value: object) -> dict[str, str]:
|
|
307
|
+
if value is None:
|
|
308
|
+
return {}
|
|
309
|
+
if not isinstance(value, dict):
|
|
310
|
+
raise ContractError(f'keys must be an object, got {value!r}')
|
|
311
|
+
keys: dict[str, str] = {}
|
|
312
|
+
for name, key in value.items():
|
|
313
|
+
if isinstance(key, bool) or not isinstance(key, str | int):
|
|
314
|
+
raise ContractError(f'key {name!r} must be a string or int, got {key!r}')
|
|
315
|
+
keys[str(name)] = str(key)
|
|
316
|
+
return keys
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _data(value: object) -> dict[str, JsonValue]:
|
|
320
|
+
if value is None:
|
|
321
|
+
return {}
|
|
322
|
+
if not isinstance(value, dict):
|
|
323
|
+
raise ContractError(f'data must be an object, got {value!r}')
|
|
324
|
+
return {str(name): item for name, item in value.items()}
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Kinro LogContract v1",
|
|
4
|
+
"description": "One JSON object per log line. Unknown fields are ignored by Kinro.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["kinro_v", "ts", "svc", "event"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"kinro_v": {"const": 1},
|
|
9
|
+
"ts": {"type": "string", "format": "date-time", "description": "RFC 3339 with an offset; event time"},
|
|
10
|
+
"svc": {"type": "string", "minLength": 1},
|
|
11
|
+
"event": {"type": "string", "minLength": 1, "description": "Dotted name, e.g. order.created; kinro.heartbeat is reserved"},
|
|
12
|
+
"level": {"type": "string", "minLength": 1},
|
|
13
|
+
"cid": {"type": "string", "minLength": 1, "description": "Propagated correlation id"},
|
|
14
|
+
"keys": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": {"type": ["string", "integer"]},
|
|
17
|
+
"description": "Business keys; integers are read as strings"
|
|
18
|
+
},
|
|
19
|
+
"data": {"type": "object", "description": "Payload; fields are dropped largest-first over the 2 KiB cap"},
|
|
20
|
+
"msg": {"type": "string", "description": "Human-readable message; not used by rules"},
|
|
21
|
+
"exc": {"type": "string", "description": "Formatted exception; not used by rules"},
|
|
22
|
+
"inst": {"type": "string", "description": "host:pid of the producing process"},
|
|
23
|
+
"seq": {"type": "integer", "minimum": 0, "description": "Per-inst counter, gap-free from 0"}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from kinro.parsers.base import LineMeta, ParseError, Parser, ParseStats
|
|
2
|
+
from kinro.parsers.contract import StandardParser
|
|
3
|
+
from kinro.parsers.gelf import GelfParser
|
|
4
|
+
from kinro.parsers.regex import RegexParser
|
|
5
|
+
from kinro.parsers.template_json import MessageRule, TemplateJsonParser
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'GelfParser',
|
|
9
|
+
'LineMeta',
|
|
10
|
+
'MessageRule',
|
|
11
|
+
'ParseError',
|
|
12
|
+
'ParseStats',
|
|
13
|
+
'Parser',
|
|
14
|
+
'RegexParser',
|
|
15
|
+
'StandardParser',
|
|
16
|
+
'TemplateJsonParser',
|
|
17
|
+
]
|