agent-observatory-cli 0.1.0__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,394 @@
1
+ """Observe local agent session metadata without changing or restarting agents.
2
+
3
+ Session files are read-only. Text, reasoning, arguments, and tool results never
4
+ enter telemetry. These adapters are best effort because agent log formats vary.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import platform
12
+ import socket
13
+ import threading
14
+ import time
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from .core import normalize_native
19
+
20
+ MAX_READ = 8 * 1024 * 1024
21
+ MAX_LINE = 2 * 1024 * 1024
22
+ MAX_SOURCES = 50
23
+
24
+
25
+ def identity(*parts, size=32):
26
+ return hashlib.sha256("\0".join(map(str, parts)).encode()).hexdigest()[:size]
27
+
28
+
29
+ def timestamp(value):
30
+ if isinstance(value, (int, float)):
31
+ magnitude = abs(value)
32
+ factor = 1 if magnitude >= 1e17 else 1000 if magnitude >= 1e14 else 1_000_000 if magnitude >= 1e11 else 1_000_000_000
33
+ return max(0, int(value * factor))
34
+ if isinstance(value, str):
35
+ try:
36
+ return max(0, int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1e9))
37
+ except ValueError:
38
+ pass
39
+ return 0
40
+
41
+
42
+ def count(value):
43
+ try:
44
+ return max(0, int(value or 0))
45
+ except (ValueError, TypeError, OverflowError):
46
+ return 0
47
+
48
+
49
+ class Session:
50
+ def __init__(self, store, path, runtime, capture_details=True):
51
+ self.store, self.path, self.runtime = store, Path(path), runtime
52
+ self.service = runtime.lower().replace(" ", "-")
53
+ self.source_id = identity(runtime, self.path.resolve())
54
+ self.capture_details = capture_details
55
+ self.internal = False
56
+ self.session_id = self.path.stem
57
+ self.offset = 0
58
+ self.file_id = None
59
+ self.pending = b""
60
+ self.turn = "session"
61
+ self.model = None
62
+ self.project = ""
63
+ self.started = {}
64
+ self.calls = {}
65
+ self.usage = {}
66
+ self.tasks = {}
67
+ self.tool_inputs = {}
68
+ self.modern_usage = False
69
+ self.last_total = None
70
+ self.last_heartbeat = 0
71
+ self.last_state = None
72
+ self.skipped = 0
73
+ self.last_activity = None
74
+
75
+ def span(self, key, name, stamp, attrs=None, *, start=None, parent=True, error=False):
76
+ if not stamp:
77
+ return None
78
+ attributes = {"session.id": self.session_id, "observatory.project": self.project,
79
+ "observatory.data_source": "local-session-log", **(attrs or {})}
80
+ return normalize_native({
81
+ "trace_id": identity(self.source_id, self.turn),
82
+ "span_id": identity(self.source_id, key, size=16) if parent else identity(self.source_id, self.turn, "root", size=16),
83
+ "parent_span_id": identity(self.source_id, self.turn, "root", size=16) if parent else None,
84
+ "name": name, "start_unix_nano": min(start or stamp, stamp), "end_unix_nano": stamp,
85
+ "status": "ERROR" if error else "OK", "attributes": attributes,
86
+ "resource_attributes": {"service.name": self.service, "host.name": socket.gethostname(),
87
+ "os.type": platform.system().lower(), "observatory.source_id": self.source_id,
88
+ "observatory.capture": "local-session-log"},
89
+ "scope_name": "observatory.local_agents",
90
+ }, self.service)
91
+
92
+ def root(self, stamp, state):
93
+ attrs = {"agent.task": "Approval review" if self.internal else self.tasks.get(self.turn) or self.runtime + (" · " + self.project if self.project else "") + " · turn",
94
+ "agent.run.state": state}
95
+ if state in {"completed", "interrupted"}:
96
+ attrs["agent.stopped_reason"] = state
97
+ return self.span((self.turn, "root"), "agent.run", stamp,
98
+ attrs, start=self.started.get(self.turn), parent=False)
99
+
100
+ def model_span(self, key, usage, stamp, *, claude=False):
101
+ if not isinstance(usage, dict):
102
+ return None
103
+ input_tokens = count(usage.get("input_tokens"))
104
+ if claude:
105
+ input_tokens += count(usage.get("cache_read_input_tokens")) + count(usage.get("cache_creation_input_tokens"))
106
+ attrs = {"gen_ai.operation.name": "chat", "gen_ai.provider.name": "anthropic" if claude else "openai",
107
+ "gen_ai.usage.input_tokens": input_tokens,
108
+ "gen_ai.usage.output_tokens": count(usage.get("output_tokens")),
109
+ "gen_ai.usage.cache_read.input_tokens": count(usage.get("cache_read_input_tokens") if claude else usage.get("cached_input_tokens")),
110
+ "observatory.duration_unknown": True}
111
+ if self.model:
112
+ attrs["gen_ai.request.model"] = str(self.model)[:200]
113
+ return self.span(key, self.runtime + " · " + str(self.model or "model call")[:200], stamp, attrs)
114
+
115
+ def codex(self, row, stamp):
116
+ kind, payload = row.get("type"), row.get("payload") or {}
117
+ if not isinstance(payload, dict):
118
+ return []
119
+ if kind == "session_meta":
120
+ self.session_id = str(payload.get("id") or payload.get("session_id") or self.session_id)
121
+ self.project = Path(payload.get("cwd") or "").name
122
+ elif kind == "turn_context":
123
+ self.turn = str(payload.get("turn_id") or self.turn)
124
+ self.model = payload.get("model") or self.model
125
+ if self.model == "codex-auto-review" and not self.internal:
126
+ self.internal = True
127
+ self.capture_details = False
128
+ self.service = "codex-review"
129
+ self.tasks.clear()
130
+ self.store.redact_internal_source(self.source_id)
131
+ self.project = Path(payload.get("cwd") or "").name
132
+ elif kind == "token_usage_record":
133
+ self.modern_usage = True
134
+ self.turn = str(payload.get("turn_id") or self.turn)
135
+ key = payload.get("response_id") or row.get("ordinal") or row.get("timestamp")
136
+ return [self.model_span(("model", key), payload.get("usage"), stamp)]
137
+ elif kind == "event_msg":
138
+ event = payload.get("type")
139
+ self.turn = str(payload.get("turn_id") or self.turn)
140
+ if event == "task_started":
141
+ self.started[self.turn] = timestamp(payload.get("started_at")) or stamp
142
+ return [self.root(stamp, "running")]
143
+ if event in {"task_complete", "task_aborted"}:
144
+ return [self.root(stamp, "completed" if event == "task_complete" else "interrupted")]
145
+ if event == "token_count" and not self.modern_usage:
146
+ info = payload.get("info") or {}
147
+ total = info.get("total_token_usage")
148
+ if not isinstance(total, dict) or total == self.last_total:
149
+ return []
150
+ usage = info.get("last_token_usage")
151
+ self.last_total = total
152
+ return [self.model_span(("usage", self.turn, json.dumps(total, sort_keys=True)), usage, stamp)]
153
+ if event == "item_completed":
154
+ item = payload.get("item") or {}
155
+ tool_type = item.get("type")
156
+ names = {"CommandExecution": "Shell command", "FileChange": "File changes",
157
+ "McpToolCall": "MCP tool", "Extension": "Extension tool"}
158
+ if tool_type in names:
159
+ attrs = {"gen_ai.operation.name": "execute_tool", "tool.name": names[tool_type]}
160
+ if tool_type == "McpToolCall":
161
+ attrs["tool.name"] = str(item.get("tool") or "MCP tool")[:128]
162
+ attrs["mcp.tool.name"] = attrs["tool.name"]
163
+ attrs["mcp.server.name"] = str(item.get("server") or "")[:128]
164
+ attrs["mcp.method.name"] = "tools/call"
165
+ code = item.get("exit_code")
166
+ if isinstance(code, int):
167
+ attrs["process.exit.code"] = code
168
+ error = item.get("status") in {"failed", "error"} or isinstance(code, int) and code != 0
169
+ end = timestamp(payload.get("completed_at_ms")) or stamp
170
+ start = timestamp(payload.get("started_at_ms"))
171
+ from .local_activity import duration
172
+ elapsed = duration(item)
173
+ if not start and elapsed is not None:
174
+ start = max(0, end-int(elapsed*1e6))
175
+ if not start:
176
+ attrs["observatory.duration_unknown"] = True
177
+ name = (attrs["mcp.server.name"] + " / " + attrs["tool.name"]) if tool_type == "McpToolCall" else str(attrs["tool.name"])
178
+ return [self.span(("tool", item.get("id") or row.get("ordinal") or stamp),
179
+ name, end, attrs, start=start, error=error)]
180
+ return []
181
+
182
+ def claude(self, row, stamp):
183
+ self.session_id = str(row.get("sessionId") or self.session_id)
184
+ self.project = Path(row.get("cwd") or "").name
185
+ message = row.get("message") or {}
186
+ if not isinstance(message, dict):
187
+ return []
188
+ content = message.get("content") or []
189
+ blocks = content if isinstance(content, list) else []
190
+ result = []
191
+ if row.get("type") == "user":
192
+ tools = [block for block in blocks if isinstance(block, dict) and block.get("type") == "tool_result"]
193
+ if not tools:
194
+ self.turn = str(row.get("uuid") or stamp)
195
+ self.started[self.turn] = stamp
196
+ result.append(self.root(stamp, "running"))
197
+ for block in tools:
198
+ key = block.get("tool_use_id")
199
+ name, start, turn = self.calls.pop(key, ("Claude tool", stamp, self.turn))
200
+ previous, self.turn = self.turn, turn
201
+ result.append(self.span(("tool", key), name, stamp,
202
+ {"gen_ai.operation.name": "execute_tool", "tool.name": name},
203
+ start=start, error=bool(block.get("is_error"))))
204
+ self.turn = previous
205
+ elif row.get("type") == "assistant":
206
+ self.model = message.get("model") or self.model
207
+ key = message.get("id") or row.get("uuid") or stamp
208
+ usage = message.get("usage")
209
+ if isinstance(usage, dict):
210
+ previous = self.usage.setdefault(key, {})
211
+ for field in ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"):
212
+ previous[field] = max(count(previous.get(field)), count(usage.get(field)))
213
+ result.append(self.model_span(("model", key), previous, stamp, claude=True))
214
+ for block in blocks:
215
+ if isinstance(block, dict) and block.get("type") == "tool_use":
216
+ self.calls[block.get("id")] = (str(block.get("name") or "Claude tool")[:128], stamp, self.turn)
217
+ if message.get("stop_reason") == "end_turn":
218
+ result.append(self.root(stamp, "completed"))
219
+ return result
220
+
221
+ def poll(self):
222
+ stat = self.path.stat()
223
+ file_id = stat.st_dev, stat.st_ino
224
+ if self.file_id != file_id or stat.st_size < self.offset:
225
+ self.offset, self.pending = 0, b""
226
+ self.file_id = file_id
227
+ self.last_total = None
228
+ self.started.clear()
229
+ self.calls.clear()
230
+ self.usage.clear()
231
+ self.tasks.clear()
232
+ self.tool_inputs.clear()
233
+ with self.path.open("rb") as stream:
234
+ if self.offset == 0 and stat.st_size > MAX_READ:
235
+ # Recover session identity from the header, then import a bounded tail.
236
+ header = stream.readline(MAX_LINE)
237
+ try:
238
+ row = json.loads(header)
239
+ if self.runtime == "Codex":
240
+ self.codex(row, timestamp(row.get("timestamp")))
241
+ except (ValueError, TypeError, AttributeError):
242
+ pass
243
+ stream.seek(stat.st_size - MAX_READ)
244
+ stream.readline()
245
+ self.offset = stream.tell()
246
+ stream.seek(self.offset)
247
+ data = stream.read(MAX_READ)
248
+ self.offset = stream.tell()
249
+ chunks = (self.pending + data).split(b"\n")
250
+ self.pending = chunks.pop()
251
+ if len(self.pending) > MAX_LINE:
252
+ self.pending = b""
253
+ self.skipped += 1
254
+ records = []
255
+ for line in chunks:
256
+ if len(line) > MAX_LINE:
257
+ self.skipped += 1
258
+ continue
259
+ try:
260
+ row = json.loads(line)
261
+ if isinstance(row, dict):
262
+ records.append(row)
263
+ except (ValueError, UnicodeDecodeError):
264
+ self.skipped += 1
265
+ if self.runtime == "Codex" and any(row.get("type") == "token_usage_record" for row in records):
266
+ self.modern_usage = True
267
+ spans = []
268
+ logs = []
269
+ from .local_activity import codex_activity, claude_activity
270
+ for row in records:
271
+ try:
272
+ stamp = timestamp(row.get("timestamp"))
273
+ spans.extend(span for span in (self.codex(row, stamp) if self.runtime == "Codex" else self.claude(row, stamp)) if span)
274
+ logs.extend(codex_activity(self, row, stamp) if self.runtime == "Codex" else claude_activity(self, row, stamp))
275
+ if self.capture_details and self.turn in self.tasks and self.turn in self.started:
276
+ payload = row.get("payload") or {}
277
+ if (self.runtime == "Codex" and isinstance(payload, dict) and (payload.get("item") or {}).get("type") == "UserMessage") or (self.runtime == "Claude Code" and row.get("type") == "user" and plaintext_task(row)):
278
+ spans.append(self.root(stamp, "running"))
279
+ except (ValueError, TypeError, KeyError, AttributeError):
280
+ self.skipped += 1
281
+ if self.internal:
282
+ for span in spans:
283
+ span['service'] = self.service
284
+ span['resource_attributes']['service.name'] = self.service
285
+ if span['name'] == 'agent.run':
286
+ span['attributes']['agent.task'] = 'Approval review'
287
+ for log in logs:
288
+ log['service'] = self.service
289
+ log['body'] = ''
290
+ log['attributes']['agent.activity.subject'] = 'Approval review'
291
+ if spans:
292
+ self.store.ingest(spans)
293
+ if logs:
294
+ self.store.ingest_logs(logs)
295
+ last = logs[-1]
296
+ self.last_activity = {"time_unix_nano": last["time_unix_nano"],
297
+ "domain": last["attributes"].get("agent.activity.domain"),
298
+ "action": last["attributes"].get("agent.activity.action"),
299
+ "subject": last["attributes"].get("agent.activity.subject")}
300
+ state = "running" if time.time() - stat.st_mtime < 35 else "stopped"
301
+ if time.monotonic() - self.last_heartbeat > 10 or state != self.last_state:
302
+ self.store.register_connection({"source_id": self.source_id, "service": self.service,
303
+ "runtime": self.runtime + " local sessions", "integrations": ["local-session-log"],
304
+ "capture_content": self.capture_details, "state": state})
305
+ self.last_heartbeat, self.last_state = time.monotonic(), state
306
+ return len(spans)
307
+
308
+
309
+ def plaintext_task(row):
310
+ message = row.get("message") or {}
311
+ if not isinstance(message, dict):
312
+ return False
313
+ content = message.get("content")
314
+ return isinstance(content, str) or isinstance(content, list) and any(isinstance(block, dict) and block.get("type") == "text" for block in content)
315
+
316
+
317
+ class LocalAgents:
318
+ def __init__(self, store, capture_details=True):
319
+ self.store = store
320
+ self.capture_details = capture_details
321
+ self.stop = threading.Event()
322
+ self.sessions = {}
323
+ self.errors = 0
324
+ self.runtime_snapshot = {}
325
+ self.runtime_updated = 0
326
+ self.thread = threading.Thread(target=self.loop, name="observatory-local-agents", daemon=True)
327
+
328
+ def discover(self):
329
+ roots = [(Path(os.environ.get("CODEX_HOME", str(Path.home()/".codex")))/"sessions", "Codex"),
330
+ (Path(os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home()/".claude")))/"projects", "Claude Code")]
331
+ recent = []
332
+ for root, runtime in roots:
333
+ if not root.is_dir():
334
+ continue
335
+ for path in root.rglob("*.jsonl"):
336
+ try:
337
+ modified = path.stat().st_mtime
338
+ if modified >= time.time() - 86400:
339
+ recent.append((modified, path, runtime))
340
+ except OSError:
341
+ self.errors += 1
342
+ selected = sorted(recent, key=lambda row: row[0], reverse=True)[:MAX_SOURCES]
343
+ keys = {path for _, path, _ in selected}
344
+ self.sessions = {path: session for path, session in self.sessions.items() if path in keys}
345
+ for _, path, runtime in selected:
346
+ if path not in self.sessions:
347
+ self.sessions[path] = Session(self.store, path, runtime, capture_details=self.capture_details)
348
+
349
+ def poll(self):
350
+ accepted = 0
351
+ for session in list(self.sessions.values()):
352
+ try:
353
+ accepted += session.poll()
354
+ except OSError:
355
+ self.errors += 1
356
+ return accepted
357
+
358
+ def loop(self):
359
+ discovered = 0
360
+ while not self.stop.is_set():
361
+ try:
362
+ if time.monotonic() - discovered >= 10:
363
+ self.discover()
364
+ discovered = time.monotonic()
365
+ self.poll()
366
+ except Exception:
367
+ self.errors += 1
368
+ self.stop.wait(1)
369
+
370
+ def start(self):
371
+ self.thread.start()
372
+ return self
373
+
374
+ def status(self):
375
+ sessions = list(self.sessions.values())
376
+ if time.monotonic() - self.runtime_updated >= 5:
377
+ from .local_runtime import snapshot
378
+ self.runtime_snapshot = snapshot()
379
+ self.runtime_updated = time.monotonic()
380
+ return {"enabled": True, "sources": len(sessions), "read_errors": self.errors,
381
+ "skipped_records": sum(session.skipped for session in sessions),
382
+ "capture_content": self.capture_details, "lookback_hours": 24, "max_sources": MAX_SOURCES,
383
+ "runtime": self.runtime_snapshot,
384
+ "sessions": [{"source_id": session.source_id, "runtime": session.runtime,
385
+ "service": session.service, "session_id": session.session_id,
386
+ "project": session.project, "model": session.model,
387
+ "task": "Approval review" if session.internal else (session.tasks.get(session.turn) or "")[:500],
388
+ "capture_content": session.capture_details,
389
+ "last_activity": session.last_activity,
390
+ "bytes_read": session.offset} for session in sessions]}
391
+
392
+ def close(self):
393
+ self.stop.set()
394
+ self.thread.join(timeout=15)
@@ -0,0 +1,49 @@
1
+ """Local host and known agent process metadata; no environment or arguments."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import platform
6
+ import socket
7
+ import time
8
+ from pathlib import Path
9
+
10
+
11
+ def snapshot():
12
+ result = {"host": socket.gethostname(), "os": platform.system(), "architecture": platform.machine(),
13
+ "collector_pid": os.getpid(), "processes": [], "process_inventory_available": Path('/proc').is_dir()}
14
+ try:
15
+ result["load_average"] = list(os.getloadavg())
16
+ except (OSError, AttributeError):
17
+ pass
18
+ if not result["process_inventory_available"]:
19
+ return result
20
+ allowed = {"codex", "claude", "claude-code", "opencode", "gemini", "ollama", "cursor"}
21
+ for proc in Path('/proc').iterdir():
22
+ if not proc.name.isdigit():
23
+ continue
24
+ try:
25
+ if proc.stat().st_uid != os.getuid():
26
+ continue
27
+ name = (proc/'comm').read_text().strip()
28
+ if name.lower() not in allowed:
29
+ continue
30
+ fields = {}
31
+ for line in (proc/'status').read_text().splitlines():
32
+ key, _, value = line.partition(':')
33
+ fields[key] = value.strip()
34
+ stat = (proc/'stat').read_text().rsplit(')', 1)[1].split()
35
+ ticks = os.sysconf('SC_CLK_TCK')
36
+ info = {"pid": int(proc.name), "name": name, "state": fields.get('State', ''),
37
+ "parent_pid": int(fields.get('PPid', 0)),
38
+ "memory_rss_kib": int(fields.get('VmRSS', '0 kB').split()[0]),
39
+ "cpu_seconds": round((int(stat[11])+int(stat[12]))/ticks, 2)}
40
+ try:
41
+ info['project'] = Path(os.readlink(proc/'cwd')).name
42
+ except OSError:
43
+ pass
44
+ result['processes'].append(info)
45
+ except (OSError, ValueError, IndexError):
46
+ continue
47
+ result['processes'] = sorted(result['processes'], key=lambda item:item['pid'])[:50]
48
+ result['observed_at_ns'] = time.time_ns()
49
+ return result
observatory/logs.py ADDED
@@ -0,0 +1,109 @@
1
+ """Normalize OTLP log records from JSON and HTTP/protobuf requests."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ from .core import TRACE_ID, SPAN_ID, _attrs, _value
7
+ from .otlp_proto import _fields, _nested, _attributes, _any_value, _text
8
+
9
+
10
+ def _optional_id(value, pattern):
11
+ if isinstance(value, str) and pattern.fullmatch(value) and any(c != "0" for c in value):
12
+ return value.lower()
13
+ return None
14
+
15
+
16
+ def _log(raw, service, resource, scope):
17
+ timestamp = int(raw.get("time_unix_nano") or raw.get("observed_time_unix_nano") or time.time_ns())
18
+ if timestamp < 0:
19
+ raise ValueError("invalid log timestamp")
20
+ attrs = raw.get("attributes") or {}
21
+ if not isinstance(attrs, dict):
22
+ raise ValueError("log attributes must be an object")
23
+ return {"time_unix_nano": timestamp,
24
+ "trace_id": _optional_id(raw.get("trace_id"), TRACE_ID),
25
+ "span_id": _optional_id(raw.get("span_id"), SPAN_ID),
26
+ "service": service, "scope_name": scope,
27
+ "resource_attributes": resource,
28
+ "severity_number": int(raw.get("severity_number") or 0),
29
+ "severity_text": str(raw.get("severity_text") or ""),
30
+ "event_name": str(raw.get("event_name") or attrs.get("event.name") or ""),
31
+ "body": raw.get("body"), "attributes": attrs}
32
+
33
+
34
+ def normalize_otlp_logs(body):
35
+ result = []
36
+ for group in body.get("resourceLogs", []):
37
+ resource = _attrs(group.get("resource", {}).get("attributes"))
38
+ service = str(resource.get("service.name") or "unknown")
39
+ for scope in group.get("scopeLogs", []):
40
+ scope_name = str(scope.get("scope", {}).get("name") or "")
41
+ for item in scope.get("logRecords", []):
42
+ raw = {"time_unix_nano": item.get("timeUnixNano"),
43
+ "observed_time_unix_nano": item.get("observedTimeUnixNano"),
44
+ "trace_id": item.get("traceId"), "span_id": item.get("spanId"),
45
+ "severity_number": item.get("severityNumber"),
46
+ "severity_text": item.get("severityText"),
47
+ "event_name": item.get("eventName"),
48
+ "body": _value(item.get("body", {})),
49
+ "attributes": _attrs(item.get("attributes"))}
50
+ result.append(_log(raw, service, resource, scope_name))
51
+ return result
52
+
53
+
54
+ def _record_proto(raw, service, resource, scope, depth):
55
+ item = {"attributes": {}}
56
+ for number, wire, value in _nested(raw, depth):
57
+ if number == 1 and wire == 1:
58
+ item["time_unix_nano"] = value
59
+ elif number == 11 and wire == 1:
60
+ item["observed_time_unix_nano"] = value
61
+ elif number == 2 and wire == 0:
62
+ item["severity_number"] = value
63
+ elif number == 3 and wire == 2:
64
+ item["severity_text"] = _text(value)
65
+ elif number == 5 and wire == 2:
66
+ item["body"] = _any_value(value, depth + 1)
67
+ elif number == 6 and wire == 2:
68
+ key, attr = _kv_log(value, depth + 1)
69
+ if key:
70
+ item["attributes"][key] = attr
71
+ elif number == 9 and wire == 2:
72
+ item["trace_id"] = value.hex()
73
+ elif number == 10 and wire == 2:
74
+ item["span_id"] = value.hex()
75
+ elif number == 12 and wire == 2:
76
+ item["event_name"] = _text(value)
77
+ return _log(item, service, resource, scope)
78
+
79
+
80
+ def _kv_log(raw, depth):
81
+ from .otlp_proto import _kv
82
+ return _kv(raw, depth)
83
+
84
+
85
+ def decode_log_request(raw):
86
+ result = []
87
+ for number, wire, group in _fields(raw):
88
+ if number != 1 or wire != 2:
89
+ continue
90
+ resource = {}
91
+ scopes = []
92
+ for field, kind, value in _nested(group, 1):
93
+ if field == 1 and kind == 2:
94
+ resource = _attributes(value, 2)
95
+ elif field == 2 and kind == 2:
96
+ scopes.append(value)
97
+ service = str(resource.get("service.name") or "unknown")
98
+ for scope in scopes:
99
+ scope_name = ""
100
+ records = []
101
+ for field, kind, value in _nested(scope, 2):
102
+ if field == 1 and kind == 2:
103
+ for key, typ, item in _nested(value, 3):
104
+ if key == 1 and typ == 2:
105
+ scope_name = _text(item)
106
+ elif field == 2 and kind == 2:
107
+ records.append(value)
108
+ result.extend(_record_proto(item, service, resource, scope_name, 3) for item in records)
109
+ return result