kinro-sdk 0.1.1__py3-none-any.whl

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,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
+ ]
kinro/contract/emit.py ADDED
@@ -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
+ ]
kinro/parsers/base.py ADDED
@@ -0,0 +1,96 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+
7
+ from kinro.contract.model import MAX_LINE_BYTES, JsonValue, Observation, ObsId
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ParseError(ValueError):
13
+ pass
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class LineMeta:
18
+ """Where a line came from: the source assigns the id and service, the parser never derives them from content.
19
+
20
+ Args:
21
+ push_ts: Loki entry time; event time only for bodies that carry no ts of their own.
22
+ """
23
+
24
+ obs_id: ObsId
25
+ service: str
26
+ push_ts: datetime | None = None
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ParseStats:
31
+ lines: int = 0
32
+ parsed: int = 0
33
+ heartbeats: int = 0
34
+ truncated: int = 0
35
+ oversize: int = 0
36
+ no_contract: int = 0
37
+ unmatched: int = 0
38
+ malformed: int = 0
39
+
40
+ @property
41
+ def errors(self) -> int:
42
+ return self.malformed + self.oversize
43
+
44
+
45
+ class Parser(ABC):
46
+ def __init__(self) -> None:
47
+ self.stats = ParseStats()
48
+
49
+ def parse(self, line: str, meta: LineMeta) -> Observation | None:
50
+ """Never raises: malformed, oversize and skipped lines only bump `stats`."""
51
+ self.stats.lines += 1
52
+ if _too_long(line):
53
+ self.stats.oversize += 1
54
+ return None
55
+ try:
56
+ obs = self._parse(line.rstrip('\r\n'), meta)
57
+ except Exception as exc:
58
+ self.stats.malformed += 1
59
+ logger.debug('Malformed %s line %s: %r', meta.service, meta.obs_id.key, exc)
60
+ return None
61
+ if obs is None:
62
+ return None
63
+ if obs.is_heartbeat:
64
+ self.stats.heartbeats += 1
65
+ else:
66
+ self.stats.parsed += 1
67
+ if obs.truncated:
68
+ self.stats.truncated += 1
69
+ return obs
70
+
71
+ @abstractmethod
72
+ def _parse(self, line: str, meta: LineMeta) -> Observation | None:
73
+ """Raise for a malformed line; count the reason in `stats` and return None for a deliberately skipped one."""
74
+
75
+
76
+ def split_groups(
77
+ groups: Mapping[str, str | None], key_names: frozenset[str], reserved: frozenset[str],
78
+ ) -> tuple[str | None, dict[str, str], dict[str, JsonValue]]:
79
+ """Route named regex groups: `cid` to cid, `key_names` to keys, everything else not reserved to payload."""
80
+ keys: dict[str, str] = {}
81
+ payload: dict[str, JsonValue] = {}
82
+ for name, value in groups.items():
83
+ if value is None or name in reserved or name == 'cid':
84
+ continue
85
+ if name in key_names:
86
+ keys[name] = value
87
+ else:
88
+ payload[name] = value
89
+ return groups.get('cid'), keys, payload
90
+
91
+
92
+ def _too_long(line: str) -> bool:
93
+ # A str of n chars encodes to at most 4n UTF-8 bytes, so short lines skip the encode.
94
+ return len(line) > MAX_LINE_BYTES or (
95
+ len(line) * 4 > MAX_LINE_BYTES and len(line.encode('utf-8', 'surrogatepass')) > MAX_LINE_BYTES
96
+ )
@@ -0,0 +1,29 @@
1
+ import json
2
+
3
+ from kinro.contract.model import LogContractV1, Observation
4
+ from kinro.parsers.base import LineMeta, ParseError, Parser
5
+
6
+
7
+ class StandardParser(Parser):
8
+ """LogContract v1 lines. The service comes from the source, not from the body's `svc`."""
9
+
10
+ def _parse(self, line: str, meta: LineMeta) -> Observation | None:
11
+ raw = json.loads(line)
12
+ if not isinstance(raw, dict):
13
+ raise ParseError('line is not a JSON object')
14
+ if 'kinro_v' not in raw:
15
+ self.stats.no_contract += 1
16
+ return None
17
+ contract = LogContractV1.from_dict(raw)
18
+ return Observation.create(
19
+ obs_id=meta.obs_id,
20
+ service=meta.service,
21
+ event=contract.event,
22
+ ts=contract.ts,
23
+ cid=contract.cid,
24
+ keys=contract.keys,
25
+ payload=contract.data,
26
+ level=contract.level,
27
+ inst=contract.inst,
28
+ seq=contract.seq,
29
+ )