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.
@@ -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
+ ]