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.
observatory/metrics.py ADDED
@@ -0,0 +1,281 @@
1
+ """Normalize OTLP metric data points from JSON and HTTP/protobuf."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import math
6
+ import re
7
+ import struct
8
+
9
+ from .core import _attrs
10
+ from .otlp_proto import _fields, _nested, _attributes, _text, _kv, _signed
11
+
12
+ KINDS = {"gauge": 5, "sum": 7, "histogram": 9,
13
+ "exponentialHistogram": 10, "summary": 11}
14
+
15
+
16
+ def _json_safe(value):
17
+ if isinstance(value, float) and not math.isfinite(value):
18
+ return str(value)
19
+ if isinstance(value, list):
20
+ return [_json_safe(item) for item in value]
21
+ if isinstance(value, dict):
22
+ return {key: _json_safe(item) for key, item in value.items()}
23
+ return value
24
+
25
+
26
+ def _normalize(service, resource, scope, metric, kind, temporality, point):
27
+ timestamp = int(point.get("timeUnixNano") or 0)
28
+ if timestamp <= 0:
29
+ raise ValueError("metric point requires timeUnixNano")
30
+ start = int(point.get("startTimeUnixNano") or 0)
31
+ value = point.get("asDouble", point.get("asInt"))
32
+ if value is not None:
33
+ value = float(value) if "asDouble" in point else int(value)
34
+ data = {key: item for key, item in point.items() if key not in
35
+ ("timeUnixNano", "startTimeUnixNano", "attributes", "asDouble", "asInt")}
36
+ if "exemplars" in point:
37
+ data["exemplars"] = _normalize_exemplars(point.get("exemplars"))
38
+ if value is not None:
39
+ data["value"] = value
40
+ return {"service": service, "resource_attributes": _json_safe(resource), "scope_name": scope,
41
+ "name": str(metric.get("name") or "unnamed"),
42
+ "description": str(metric.get("description") or ""),
43
+ "unit": str(metric.get("unit") or ""), "kind": kind,
44
+ "temporality": temporality, "time_unix_nano": timestamp,
45
+ "start_unix_nano": start, "attributes": _json_safe(_attrs(point.get("attributes"))),
46
+ "data": _json_safe(data)}
47
+
48
+
49
+
50
+ def _normalize_exemplars(items):
51
+ result = []
52
+ for item in items or []:
53
+ if not isinstance(item, dict):
54
+ continue
55
+ trace_id = item.get("trace_id", item.get("traceId"))
56
+ span_id = item.get("span_id", item.get("spanId"))
57
+ if isinstance(trace_id, bytes):
58
+ trace_id = trace_id.hex()
59
+ if isinstance(span_id, bytes):
60
+ span_id = span_id.hex()
61
+ trace_id = str(trace_id).lower() if trace_id is not None else ""
62
+ span_id = str(span_id).lower() if span_id is not None else ""
63
+ if trace_id and not re.fullmatch(r"[0-9a-f]{32}", trace_id):
64
+ trace_id = ""
65
+ if span_id and not re.fullmatch(r"[0-9a-f]{16}", span_id):
66
+ span_id = ""
67
+ try:
68
+ time_ns = max(0, int(item.get("time_unix_nano", item.get("timeUnixNano", 0))))
69
+ except (TypeError, ValueError, OverflowError):
70
+ time_ns = 0
71
+ value = item.get("value", item.get("asDouble", item.get("asInt")))
72
+ attrs = item.get("filtered_attributes", item.get("filteredAttributes", {}))
73
+ if isinstance(attrs, list):
74
+ attrs = _attrs(attrs)
75
+ result.append({"trace_id": trace_id or None, "span_id": span_id or None,
76
+ "time_unix_nano": time_ns, "value": _json_safe(value),
77
+ "filtered_attributes": _json_safe(attrs if isinstance(attrs, dict) else {})})
78
+ return result
79
+
80
+
81
+ def _exemplar(raw, depth):
82
+ exemplar = {}
83
+ filtered = {}
84
+ for number, wire, value in _nested(raw, depth):
85
+ if number == 2 and wire == 1:
86
+ exemplar["time_unix_nano"] = value
87
+ elif number == 3 and wire == 1:
88
+ exemplar["value"] = _double(value)
89
+ elif number == 4 and wire == 2:
90
+ exemplar["span_id"] = value.hex()
91
+ elif number == 5 and wire == 2:
92
+ exemplar["trace_id"] = value.hex()
93
+ elif number == 6 and wire == 1:
94
+ exemplar["value"] = _signed(value)
95
+ elif number == 7 and wire == 2:
96
+ key, item = _kv(value, depth + 1)
97
+ if key:
98
+ filtered[key] = item
99
+ if filtered:
100
+ exemplar["filtered_attributes"] = filtered
101
+ return exemplar
102
+
103
+ def normalize_otlp_metrics(body):
104
+ result = []
105
+ for group in body.get("resourceMetrics", []):
106
+ resource = _attrs(group.get("resource", {}).get("attributes"))
107
+ service = str(resource.get("service.name") or "unknown")
108
+ for scope in group.get("scopeMetrics", []):
109
+ scope_name = str(scope.get("scope", {}).get("name") or "")
110
+ for metric in scope.get("metrics", []):
111
+ matched = False
112
+ for kind in KINDS:
113
+ if kind in metric:
114
+ matched = True
115
+ payload = metric[kind]
116
+ temporality = int(payload.get("aggregationTemporality") or 0)
117
+ result.extend(_normalize(service, resource, scope_name, metric,
118
+ kind, temporality, point)
119
+ for point in payload.get("dataPoints", []))
120
+ break
121
+ if not matched:
122
+ raise ValueError(f"metric {metric.get('name', 'unnamed')} has no supported data type")
123
+ return result
124
+
125
+
126
+ def _double(value):
127
+ return struct.unpack("<d", value.to_bytes(8, "little"))[0]
128
+
129
+
130
+ def _packed_fixed64(value):
131
+ if len(value) % 8:
132
+ raise ValueError("invalid packed fixed64 array")
133
+ return [int.from_bytes(value[i:i + 8], "little") for i in range(0, len(value), 8)]
134
+
135
+
136
+ def _point(raw, kind, depth):
137
+ attrs_field = 1 if kind == "exponentialHistogram" else 9 if kind == "histogram" else 7
138
+ point = {"attributes": []}
139
+ buckets = {"positive": None, "negative": None}
140
+ exemplar_field = 5 if kind in ("gauge", "sum") else 8 if kind == "histogram" else 11 if kind == "exponentialHistogram" else None
141
+ for number, wire, value in _nested(raw, depth):
142
+ if number == exemplar_field and wire == 2:
143
+ point.setdefault("exemplars", []).append(_exemplar(value, depth + 1))
144
+ elif number == attrs_field and wire == 2:
145
+ key, item = _kv(value, depth + 1)
146
+ if key:
147
+ point["attributes"].append({"key": key, "value": _json_any(item)})
148
+ elif number == 2 and wire == 1:
149
+ point["startTimeUnixNano"] = value
150
+ elif number == 3 and wire == 1:
151
+ point["timeUnixNano"] = value
152
+ elif kind in ("gauge", "sum"):
153
+ if number == 4 and wire == 1:
154
+ point["asDouble"] = _double(value)
155
+ elif number == 6 and wire == 1:
156
+ point["asInt"] = _signed(value)
157
+ elif kind in ("histogram", "exponentialHistogram", "summary"):
158
+ if number == 4 and wire == 1:
159
+ point["count"] = value
160
+ elif number == 5 and wire == 1:
161
+ point["sum"] = _double(value)
162
+ elif kind == "histogram":
163
+ if number == 6 and wire == 1:
164
+ point.setdefault("bucketCounts", []).append(value)
165
+ elif number == 6 and wire == 2:
166
+ point.setdefault("bucketCounts", []).extend(_packed_fixed64(value))
167
+ elif number == 7 and wire == 1:
168
+ point.setdefault("explicitBounds", []).append(_double(value))
169
+ elif number == 7 and wire == 2:
170
+ point.setdefault("explicitBounds", []).extend(_double(v) for v in _packed_fixed64(value))
171
+ elif number in (11, 12) and wire == 1:
172
+ point["min" if number == 11 else "max"] = _double(value)
173
+ elif kind == "exponentialHistogram":
174
+ if number == 6 and wire == 0:
175
+ point["scale"] = (value >> 1) ^ -(value & 1)
176
+ elif number == 7 and wire == 1:
177
+ point["zeroCount"] = value
178
+ elif number in (8, 9) and wire == 2:
179
+ buckets["positive" if number == 8 else "negative"] = _exp_buckets(value, depth + 1)
180
+ elif number in (12, 13, 14) and wire == 1:
181
+ point[{12: "min", 13: "max", 14: "zeroThreshold"}[number]] = _double(value)
182
+ elif kind == "summary" and number == 6 and wire == 2:
183
+ quantile = {}
184
+ for field, typ, val in _nested(value, depth + 1):
185
+ if field in (1, 2) and typ == 1:
186
+ quantile["quantile" if field == 1 else "value"] = _double(val)
187
+ point.setdefault("quantileValues", []).append(quantile)
188
+ if kind == "exponentialHistogram":
189
+ point.update({key: value for key, value in buckets.items() if value is not None})
190
+ return point
191
+
192
+
193
+ def _json_any(value):
194
+ if isinstance(value, str):
195
+ return {"stringValue": value}
196
+ if isinstance(value, bool):
197
+ return {"boolValue": value}
198
+ if isinstance(value, int):
199
+ return {"intValue": value}
200
+ if isinstance(value, float):
201
+ return {"doubleValue": value}
202
+ if isinstance(value, bytes):
203
+ return {"bytesValue": base64.b64encode(value).decode()}
204
+ if isinstance(value, list):
205
+ return {"arrayValue": {"values": [_json_any(v) for v in value]}}
206
+ if isinstance(value, dict):
207
+ return {"kvlistValue": {"values": [{"key": k, "value": _json_any(v)} for k, v in value.items()]}}
208
+ return {}
209
+
210
+
211
+ def _exp_buckets(raw, depth):
212
+ result = {"offset": 0, "bucketCounts": []}
213
+ for number, wire, value in _nested(raw, depth):
214
+ if number == 1 and wire == 0:
215
+ result["offset"] = (value >> 1) ^ -(value & 1)
216
+ elif number == 2 and wire == 0:
217
+ result["bucketCounts"].append(value)
218
+ elif number == 2 and wire == 2:
219
+ result["bucketCounts"].extend(_unpack_varints(value))
220
+ return result
221
+
222
+
223
+ def _unpack_varints(raw):
224
+ from .otlp_proto import _varint
225
+ data = memoryview(raw)
226
+ pos = 0
227
+ values = []
228
+ while pos < len(data):
229
+ value, pos = _varint(data, pos)
230
+ values.append(value)
231
+ return values
232
+
233
+
234
+ def _metric(raw, service, resource, scope, depth):
235
+ metric = {"name": "", "description": "", "unit": ""}
236
+ payload = None
237
+ kind = None
238
+ for number, wire, value in _nested(raw, depth):
239
+ if number in (1, 2, 3) and wire == 2:
240
+ metric[{1: "name", 2: "description", 3: "unit"}[number]] = _text(value)
241
+ elif number in KINDS.values() and wire == 2:
242
+ kind = next(name for name, field in KINDS.items() if field == number)
243
+ payload = value
244
+ if payload is None:
245
+ raise ValueError(f"metric {metric['name'] or 'unnamed'} has no supported data type")
246
+ points = []
247
+ temporality = 0
248
+ for number, wire, value in _nested(payload, depth + 1):
249
+ if number == 1 and wire == 2:
250
+ points.append(_point(value, kind, depth + 2))
251
+ elif number == 2 and wire == 0:
252
+ temporality = value
253
+ return [_normalize(service, resource, scope, metric, kind, temporality, point) for point in points]
254
+
255
+
256
+ def decode_metric_request(raw):
257
+ result = []
258
+ for number, wire, group in _fields(raw):
259
+ if number != 1 or wire != 2:
260
+ continue
261
+ resource = {}
262
+ scopes = []
263
+ for field, kind, value in _nested(group, 1):
264
+ if field == 1 and kind == 2:
265
+ resource = _attributes(value, 2)
266
+ elif field == 2 and kind == 2:
267
+ scopes.append(value)
268
+ service = str(resource.get("service.name") or "unknown")
269
+ for scope in scopes:
270
+ scope_name = ""
271
+ metrics = []
272
+ for field, kind, value in _nested(scope, 2):
273
+ if field == 1 and kind == 2:
274
+ for key, typ, item in _nested(value, 3):
275
+ if key == 1 and typ == 2:
276
+ scope_name = _text(item)
277
+ elif field == 2 and kind == 2:
278
+ metrics.append(value)
279
+ for metric in metrics:
280
+ result.extend(_metric(metric, service, resource, scope_name, 3))
281
+ return result
@@ -0,0 +1,210 @@
1
+ """Decode OTLP/HTTP protobuf trace requests without a protobuf runtime.
2
+
3
+ Field numbers follow opentelemetry-proto's collector/trace, trace, resource,
4
+ and common v1 messages. Unknown fields are skipped as protobuf requires.
5
+ This module handles the trace signal only; it is not a general protobuf library.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import struct
11
+
12
+ from .core import normalize_native
13
+
14
+ MAX_DEPTH = 16
15
+
16
+
17
+ def _varint(data: memoryview, pos: int):
18
+ value = 0
19
+ for shift in range(0, 70, 7):
20
+ if pos >= len(data):
21
+ raise ValueError("truncated protobuf varint")
22
+ byte = data[pos]
23
+ pos += 1
24
+ value |= (byte & 0x7f) << shift
25
+ if byte < 128:
26
+ if value >= 1 << 64:
27
+ raise ValueError("protobuf varint exceeds uint64")
28
+ return value, pos
29
+ raise ValueError("protobuf varint exceeds 10 bytes")
30
+
31
+
32
+ def _fields(raw: bytes, depth=0):
33
+ if depth > MAX_DEPTH:
34
+ raise ValueError("protobuf nesting is too deep")
35
+ data = memoryview(raw)
36
+ pos = 0
37
+ while pos < len(data):
38
+ tag, pos = _varint(data, pos)
39
+ number, wire = tag >> 3, tag & 7
40
+ if not number:
41
+ raise ValueError("invalid protobuf field number")
42
+ if wire == 0:
43
+ value, pos = _varint(data, pos)
44
+ elif wire == 1:
45
+ if len(data) - pos < 8:
46
+ raise ValueError("truncated protobuf fixed64")
47
+ value = int.from_bytes(data[pos:pos + 8], "little")
48
+ pos += 8
49
+ elif wire == 2:
50
+ size, pos = _varint(data, pos)
51
+ if size > len(data) - pos:
52
+ raise ValueError("truncated protobuf field")
53
+ value = bytes(data[pos:pos + size])
54
+ pos += size
55
+ elif wire == 5:
56
+ if len(data) - pos < 4:
57
+ raise ValueError("truncated protobuf fixed32")
58
+ value = int.from_bytes(data[pos:pos + 4], "little")
59
+ pos += 4
60
+ else:
61
+ raise ValueError(f"unsupported protobuf wire type {wire}")
62
+ yield number, wire, value
63
+
64
+
65
+ def _text(value):
66
+ return value.decode("utf-8")
67
+
68
+
69
+ def _nested(value, depth):
70
+ return _fields(value, depth + 1)
71
+
72
+
73
+ def _signed(value):
74
+ return value - (1 << 64) if value >= (1 << 63) else value
75
+
76
+
77
+ def _any_value(raw, depth):
78
+ result = None
79
+ for number, wire, value in _nested(raw, depth):
80
+ if number == 1 and wire == 2:
81
+ result = _text(value)
82
+ elif number == 2 and wire == 0:
83
+ result = bool(value)
84
+ elif number == 3 and wire == 0:
85
+ result = _signed(value)
86
+ elif number == 4 and wire == 1:
87
+ result = struct.unpack("<d", value.to_bytes(8, "little"))[0]
88
+ elif number == 5 and wire == 2:
89
+ result = [_any_value(v, depth + 2) for n, w, v in _nested(value, depth + 1) if n == 1 and w == 2]
90
+ elif number == 6 and wire == 2:
91
+ result = _attributes(value, depth + 1)
92
+ elif number == 7 and wire == 2:
93
+ result = base64.b64encode(value).decode("ascii")
94
+ return result
95
+
96
+
97
+ def _kv(raw, depth):
98
+ key, value = "", None
99
+ for number, wire, item in _nested(raw, depth):
100
+ if number == 1 and wire == 2:
101
+ key = _text(item)
102
+ elif number == 2 and wire == 2:
103
+ value = _any_value(item, depth + 1)
104
+ return key, value
105
+
106
+
107
+ def _attributes(raw, depth, field=1):
108
+ result = {}
109
+ for number, wire, value in _nested(raw, depth):
110
+ if number == field and wire == 2:
111
+ key, item = _kv(value, depth + 1)
112
+ if key:
113
+ result[key] = item
114
+ return result
115
+
116
+
117
+ def _event(raw, depth):
118
+ event = {"name": "event", "time_unix_nano": 0, "attributes": {}}
119
+ for number, wire, value in _nested(raw, depth):
120
+ if number == 1 and wire == 1:
121
+ event["time_unix_nano"] = value
122
+ elif number == 2 and wire == 2:
123
+ event["name"] = _text(value)
124
+ elif number == 3 and wire == 2:
125
+ key, item = _kv(value, depth + 1)
126
+ if key:
127
+ event["attributes"][key] = item
128
+ return event
129
+
130
+
131
+ def _status(raw, depth):
132
+ code = 0
133
+ for number, wire, value in _nested(raw, depth):
134
+ if number == 3 and wire == 0:
135
+ code = value
136
+ return {0: "UNSET", 1: "OK", 2: "ERROR"}.get(code, "UNSET")
137
+
138
+
139
+ def _link(raw, depth):
140
+ result = {"trace_id": None, "span_id": None, "attributes": {}}
141
+ for number, wire, value in _nested(raw, depth):
142
+ if number == 1 and wire == 2:
143
+ result["trace_id"] = value.hex()
144
+ elif number == 2 and wire == 2:
145
+ result["span_id"] = value.hex()
146
+ elif number == 4 and wire == 2:
147
+ key, item = _kv(value, depth + 1)
148
+ if key:
149
+ result["attributes"][key] = item
150
+ return result
151
+
152
+
153
+ def _span(raw, service, resource, scope_name, depth):
154
+ native = {"trace_id": None, "span_id": None, "parent_span_id": None,
155
+ "name": "unnamed", "start_unix_nano": 0, "end_unix_nano": 0,
156
+ "status": "UNSET", "attributes": {}, "events": [], "links": [],
157
+ "resource_attributes": resource, "scope_name": scope_name}
158
+ for number, wire, value in _nested(raw, depth):
159
+ if number == 1 and wire == 2:
160
+ native["trace_id"] = value.hex()
161
+ elif number == 2 and wire == 2:
162
+ native["span_id"] = value.hex()
163
+ elif number == 4 and wire == 2:
164
+ native["parent_span_id"] = value.hex() if value else None
165
+ elif number == 5 and wire == 2:
166
+ native["name"] = _text(value)
167
+ elif number == 7 and wire == 1:
168
+ native["start_unix_nano"] = value
169
+ elif number == 8 and wire == 1:
170
+ native["end_unix_nano"] = value
171
+ elif number == 9 and wire == 2:
172
+ key, item = _kv(value, depth + 1)
173
+ if key:
174
+ native["attributes"][key] = item
175
+ elif number == 11 and wire == 2:
176
+ native["events"].append(_event(value, depth + 1))
177
+ elif number == 13 and wire == 2:
178
+ native["links"].append(_link(value, depth + 1))
179
+ elif number == 15 and wire == 2:
180
+ native["status"] = _status(value, depth + 1)
181
+ return normalize_native(native, service)
182
+
183
+
184
+ def decode_trace_request(raw: bytes):
185
+ """Return normalized spans from an ExportTraceServiceRequest protobuf body."""
186
+ spans = []
187
+ for number, wire, resource_spans in _fields(raw):
188
+ if number != 1 or wire != 2:
189
+ continue
190
+ resource = {}
191
+ scopes = []
192
+ for field, kind, value in _nested(resource_spans, 1):
193
+ if field == 1 and kind == 2:
194
+ resource = _attributes(value, 2)
195
+ elif field == 2 and kind == 2:
196
+ scopes.append(value)
197
+ for scope in scopes:
198
+ scope_name = ""
199
+ raw_spans = []
200
+ for field, kind, value in _nested(scope, 2):
201
+ if field == 1 and kind == 2:
202
+ for scope_field, scope_kind, scope_value in _nested(value, 3):
203
+ if scope_field == 1 and scope_kind == 2:
204
+ scope_name = _text(scope_value)
205
+ elif field == 2 and kind == 2:
206
+ raw_spans.append(value)
207
+ for span in raw_spans:
208
+ spans.append(_span(span, str(resource.get("service.name") or "unknown"),
209
+ resource, scope_name, 3))
210
+ return spans