agentevals-cli 0.5.2__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.
- agentevals/__init__.py +16 -0
- agentevals/_protocol.py +83 -0
- agentevals/api/__init__.py +0 -0
- agentevals/api/app.py +137 -0
- agentevals/api/debug_routes.py +268 -0
- agentevals/api/models.py +204 -0
- agentevals/api/otlp_app.py +25 -0
- agentevals/api/otlp_routes.py +383 -0
- agentevals/api/routes.py +554 -0
- agentevals/api/streaming_routes.py +373 -0
- agentevals/builtin_metrics.py +234 -0
- agentevals/cli.py +643 -0
- agentevals/config.py +108 -0
- agentevals/converter.py +328 -0
- agentevals/custom_evaluators.py +468 -0
- agentevals/eval_config_loader.py +147 -0
- agentevals/evaluator/__init__.py +24 -0
- agentevals/evaluator/resolver.py +70 -0
- agentevals/evaluator/sources.py +293 -0
- agentevals/evaluator/templates.py +224 -0
- agentevals/extraction.py +444 -0
- agentevals/genai_converter.py +538 -0
- agentevals/loader/__init__.py +7 -0
- agentevals/loader/base.py +53 -0
- agentevals/loader/jaeger.py +112 -0
- agentevals/loader/otlp.py +193 -0
- agentevals/mcp_server.py +236 -0
- agentevals/output.py +204 -0
- agentevals/runner.py +310 -0
- agentevals/sdk.py +433 -0
- agentevals/streaming/__init__.py +120 -0
- agentevals/streaming/incremental_processor.py +337 -0
- agentevals/streaming/processor.py +285 -0
- agentevals/streaming/session.py +36 -0
- agentevals/streaming/ws_server.py +806 -0
- agentevals/trace_attrs.py +32 -0
- agentevals/trace_metrics.py +126 -0
- agentevals/utils/__init__.py +0 -0
- agentevals/utils/genai_messages.py +142 -0
- agentevals/utils/log_buffer.py +43 -0
- agentevals/utils/log_enrichment.py +187 -0
- agentevals_cli-0.5.2.dist-info/METADATA +22 -0
- agentevals_cli-0.5.2.dist-info/RECORD +46 -0
- agentevals_cli-0.5.2.dist-info/WHEEL +4 -0
- agentevals_cli-0.5.2.dist-info/entry_points.txt +2 -0
- agentevals_cli-0.5.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Minimal FastAPI app for the OTLP HTTP receiver on port 4318.
|
|
2
|
+
|
|
3
|
+
Shares the StreamingTraceManager with the main app (port 8001).
|
|
4
|
+
Mounts only the /v1/traces and /v1/logs endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
from .otlp_routes import otlp_router, set_trace_manager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def lifespan(app: FastAPI):
|
|
16
|
+
from .app import get_trace_manager
|
|
17
|
+
|
|
18
|
+
mgr = get_trace_manager()
|
|
19
|
+
if mgr:
|
|
20
|
+
set_trace_manager(mgr)
|
|
21
|
+
yield
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
otlp_app = FastAPI(title="agentevals OTLP receiver", lifespan=lifespan)
|
|
25
|
+
otlp_app.include_router(otlp_router)
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""OTLP HTTP receiver endpoints for /v1/traces and /v1/logs.
|
|
2
|
+
|
|
3
|
+
Accepts standard OTLP/HTTP payloads (ExportTraceServiceRequest,
|
|
4
|
+
ExportLogsServiceRequest) in both JSON and protobuf wire formats,
|
|
5
|
+
and feeds them into the existing streaming pipeline via
|
|
6
|
+
StreamingTraceManager.
|
|
7
|
+
|
|
8
|
+
Runs on port 4318 (standard OTLP HTTP port). Agents send traces by setting:
|
|
9
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import logging
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, Request, Response
|
|
19
|
+
from google.protobuf.json_format import MessageToDict
|
|
20
|
+
from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
|
|
21
|
+
ExportLogsServiceRequest as LogsServiceRequestPB,
|
|
22
|
+
)
|
|
23
|
+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
|
|
24
|
+
ExportTraceServiceRequest as TraceServiceRequestPB,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ..extraction import flatten_otlp_attributes
|
|
28
|
+
from ..trace_attrs import (
|
|
29
|
+
OTEL_GENAI_INPUT_MESSAGES,
|
|
30
|
+
OTEL_GENAI_OUTPUT_MESSAGES,
|
|
31
|
+
OTEL_SCOPE,
|
|
32
|
+
OTEL_SCOPE_VERSION,
|
|
33
|
+
)
|
|
34
|
+
from .models import WSSpanReceivedEvent
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from ..streaming.ws_server import StreamingTraceManager
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
otlp_router = APIRouter()
|
|
42
|
+
_trace_manager: StreamingTraceManager | None = None
|
|
43
|
+
|
|
44
|
+
AGENTEVALS_EVAL_SET_ID = "agentevals.eval_set_id"
|
|
45
|
+
AGENTEVALS_SESSION_NAME = "agentevals.session_name"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_trace_manager(manager: StreamingTraceManager) -> None:
|
|
49
|
+
global _trace_manager
|
|
50
|
+
_trace_manager = manager
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@otlp_router.post("/v1/traces")
|
|
54
|
+
async def receive_traces(request: Request) -> Response:
|
|
55
|
+
"""OTLP HTTP trace receiver (ExportTraceServiceRequest)."""
|
|
56
|
+
if not _trace_manager:
|
|
57
|
+
return Response(status_code=503, content="Live mode not enabled")
|
|
58
|
+
|
|
59
|
+
content_type = request.headers.get("content-type", "")
|
|
60
|
+
|
|
61
|
+
if "application/x-protobuf" in content_type:
|
|
62
|
+
raw = await request.body()
|
|
63
|
+
body = _decode_protobuf_traces(raw)
|
|
64
|
+
else:
|
|
65
|
+
body = await request.json()
|
|
66
|
+
|
|
67
|
+
await _process_traces(body)
|
|
68
|
+
return Response(
|
|
69
|
+
status_code=200,
|
|
70
|
+
content='{"partialSuccess":{}}',
|
|
71
|
+
media_type="application/json",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@otlp_router.post("/v1/logs")
|
|
76
|
+
async def receive_logs(request: Request) -> Response:
|
|
77
|
+
"""OTLP HTTP log receiver (ExportLogsServiceRequest)."""
|
|
78
|
+
if not _trace_manager:
|
|
79
|
+
return Response(status_code=503, content="Live mode not enabled")
|
|
80
|
+
|
|
81
|
+
content_type = request.headers.get("content-type", "")
|
|
82
|
+
|
|
83
|
+
if "application/x-protobuf" in content_type:
|
|
84
|
+
raw = await request.body()
|
|
85
|
+
body = _decode_protobuf_logs(raw)
|
|
86
|
+
else:
|
|
87
|
+
body = await request.json()
|
|
88
|
+
|
|
89
|
+
await _process_logs(body)
|
|
90
|
+
return Response(
|
|
91
|
+
status_code=200,
|
|
92
|
+
content='{"partialSuccess":{}}',
|
|
93
|
+
media_type="application/json",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _process_traces(body: dict) -> None:
|
|
98
|
+
"""Parse ExportTraceServiceRequest and feed spans to the pipeline."""
|
|
99
|
+
for resource_span in body.get("resourceSpans", []):
|
|
100
|
+
resource_attrs = resource_span.get("resource", {}).get("attributes", [])
|
|
101
|
+
metadata = _extract_agentevals_metadata(resource_attrs)
|
|
102
|
+
|
|
103
|
+
for scope_span in resource_span.get("scopeSpans", []):
|
|
104
|
+
scope = scope_span.get("scope", {})
|
|
105
|
+
scope_name = scope.get("name", "")
|
|
106
|
+
scope_version = scope.get("version", "")
|
|
107
|
+
|
|
108
|
+
for span_data in scope_span.get("spans", []):
|
|
109
|
+
span = _normalize_span(span_data, scope_name, scope_version)
|
|
110
|
+
trace_id = span.get("traceId", "")
|
|
111
|
+
|
|
112
|
+
if not trace_id:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
session = await _trace_manager.get_or_create_otlp_session(trace_id, metadata)
|
|
116
|
+
|
|
117
|
+
if not session.can_accept_span():
|
|
118
|
+
logger.warning("Session %s at span limit", session.session_id)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
session.spans.append(span)
|
|
122
|
+
|
|
123
|
+
extractor = _trace_manager.incremental_extractors.get(session.session_id)
|
|
124
|
+
if extractor:
|
|
125
|
+
updates = extractor.process_span(span)
|
|
126
|
+
for update in updates:
|
|
127
|
+
update["sessionId"] = session.session_id
|
|
128
|
+
await _trace_manager.broadcast_to_ui(update)
|
|
129
|
+
|
|
130
|
+
await _trace_manager.broadcast_to_ui(
|
|
131
|
+
WSSpanReceivedEvent(
|
|
132
|
+
session_id=session.session_id,
|
|
133
|
+
span=span,
|
|
134
|
+
).model_dump(by_alias=True)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
_trace_manager.reset_idle_timer(session.session_id)
|
|
138
|
+
|
|
139
|
+
if not span.get("parentSpanId"):
|
|
140
|
+
session.has_root_span = True
|
|
141
|
+
_trace_manager.schedule_session_completion(session.session_id)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _process_logs(body: dict) -> None:
|
|
145
|
+
"""Parse ExportLogsServiceRequest and feed logs to sessions.
|
|
146
|
+
|
|
147
|
+
Logs and spans arrive via separate OTLP exporters (BatchLogRecordProcessor
|
|
148
|
+
and BatchSpanProcessor) and may arrive in any order. When a log's traceId
|
|
149
|
+
isn't yet registered in a session's trace_ids set, we fall back to matching
|
|
150
|
+
by session_name from resource attributes.
|
|
151
|
+
|
|
152
|
+
Logs may arrive after span-triggered session completion (the
|
|
153
|
+
BatchLogRecordProcessor and BatchSpanProcessor flush independently).
|
|
154
|
+
Late-arriving logs are accepted and trigger re-extraction of invocations.
|
|
155
|
+
"""
|
|
156
|
+
sessions_needing_reextraction: set[str] = set()
|
|
157
|
+
|
|
158
|
+
for resource_log in body.get("resourceLogs", []):
|
|
159
|
+
resource_attrs = resource_log.get("resource", {}).get("attributes", [])
|
|
160
|
+
metadata = _extract_agentevals_metadata(resource_attrs)
|
|
161
|
+
session_name = metadata.get("session_name")
|
|
162
|
+
|
|
163
|
+
for scope_log in resource_log.get("scopeLogs", []):
|
|
164
|
+
for log_record in scope_log.get("logRecords", []):
|
|
165
|
+
log_event = _convert_otlp_log_record(log_record)
|
|
166
|
+
if not log_event:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
trace_id = log_record.get("traceId", "")
|
|
170
|
+
if not trace_id:
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
session = _trace_manager.find_session_by_trace_id(trace_id)
|
|
174
|
+
|
|
175
|
+
if not session and session_name and _trace_manager:
|
|
176
|
+
active_id = _trace_manager._active_session_for_name.get(session_name)
|
|
177
|
+
candidate = _trace_manager.sessions.get(active_id) if active_id else None
|
|
178
|
+
if candidate and not candidate.is_complete:
|
|
179
|
+
candidate.trace_ids.add(trace_id)
|
|
180
|
+
session = candidate
|
|
181
|
+
|
|
182
|
+
if not session:
|
|
183
|
+
if _trace_manager:
|
|
184
|
+
_trace_manager.buffer_orphan_log(trace_id, session_name, log_event)
|
|
185
|
+
logger.debug(
|
|
186
|
+
"Buffered orphan log trace_id=%s session_name=%s",
|
|
187
|
+
trace_id[:12],
|
|
188
|
+
session_name,
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if not session.can_accept_log():
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
session.logs.append(log_event)
|
|
196
|
+
|
|
197
|
+
if session.is_complete:
|
|
198
|
+
sessions_needing_reextraction.add(session.session_id)
|
|
199
|
+
else:
|
|
200
|
+
_trace_manager.reset_idle_timer(session.session_id)
|
|
201
|
+
|
|
202
|
+
extractor = _trace_manager.incremental_extractors.get(session.session_id)
|
|
203
|
+
if extractor:
|
|
204
|
+
updates = extractor.process_log(log_event)
|
|
205
|
+
for update in updates:
|
|
206
|
+
update["sessionId"] = session.session_id
|
|
207
|
+
await _trace_manager.broadcast_to_ui(update)
|
|
208
|
+
|
|
209
|
+
for session_id in sessions_needing_reextraction:
|
|
210
|
+
_trace_manager.schedule_log_reextraction(session_id)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_GENAI_EVENT_KEYS = {OTEL_GENAI_INPUT_MESSAGES, OTEL_GENAI_OUTPUT_MESSAGES}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _normalize_span(span_data: dict, scope_name: str, scope_version: str) -> dict:
|
|
217
|
+
"""Normalize an OTLP span for the downstream pipeline.
|
|
218
|
+
|
|
219
|
+
Performs two transformations:
|
|
220
|
+
1. Injects otel.scope.name/version from the scopeSpans level into span
|
|
221
|
+
attributes (the pipeline expects them there).
|
|
222
|
+
2. Promotes gen_ai.input.messages and gen_ai.output.messages from span
|
|
223
|
+
events to span attributes. Some SDKs (e.g. Strands with
|
|
224
|
+
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental) store
|
|
225
|
+
message content in span events, but the converter reads attributes.
|
|
226
|
+
"""
|
|
227
|
+
span = dict(span_data)
|
|
228
|
+
attrs = list(span.get("attributes", []))
|
|
229
|
+
|
|
230
|
+
existing_keys = {a.get("key") for a in attrs}
|
|
231
|
+
|
|
232
|
+
if scope_name and OTEL_SCOPE not in existing_keys:
|
|
233
|
+
attrs.append({"key": OTEL_SCOPE, "value": {"stringValue": scope_name}})
|
|
234
|
+
existing_keys.add(OTEL_SCOPE)
|
|
235
|
+
if scope_version and OTEL_SCOPE_VERSION not in existing_keys:
|
|
236
|
+
attrs.append({"key": OTEL_SCOPE_VERSION, "value": {"stringValue": scope_version}})
|
|
237
|
+
existing_keys.add(OTEL_SCOPE_VERSION)
|
|
238
|
+
|
|
239
|
+
for event in span.get("events", []):
|
|
240
|
+
for attr in event.get("attributes", []):
|
|
241
|
+
key = attr.get("key", "")
|
|
242
|
+
if key in _GENAI_EVENT_KEYS and key not in existing_keys:
|
|
243
|
+
attrs.append(attr)
|
|
244
|
+
existing_keys.add(key)
|
|
245
|
+
|
|
246
|
+
span["attributes"] = attrs
|
|
247
|
+
return span
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _extract_agentevals_metadata(resource_attrs: list[dict]) -> dict:
|
|
251
|
+
"""Extract agentevals-specific metadata from OTLP resource attributes."""
|
|
252
|
+
flat = flatten_otlp_attributes(resource_attrs)
|
|
253
|
+
return {
|
|
254
|
+
"eval_set_id": flat.get(AGENTEVALS_EVAL_SET_ID),
|
|
255
|
+
"session_name": flat.get(AGENTEVALS_SESSION_NAME),
|
|
256
|
+
"service_name": flat.get("service.name"),
|
|
257
|
+
"resource_attrs": flat,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _convert_otlp_log_record(log_record: dict) -> dict | None:
|
|
262
|
+
"""Convert OTLP log record to internal log event format.
|
|
263
|
+
|
|
264
|
+
Internal format (used by IncrementalInvocationExtractor.process_log()):
|
|
265
|
+
{"event_name": "gen_ai.user.message", "timestamp": ..., "body": {...}, "attributes": {...}}
|
|
266
|
+
|
|
267
|
+
Handles two event-name conventions:
|
|
268
|
+
- Newer OTel SDKs: top-level ``eventName`` field (LogRecord.event_name proto)
|
|
269
|
+
- Older convention: ``event.name`` stored as a regular attribute
|
|
270
|
+
"""
|
|
271
|
+
attrs = flatten_otlp_attributes(log_record.get("attributes", []))
|
|
272
|
+
event_name = log_record.get("eventName") or attrs.get("event.name", "")
|
|
273
|
+
|
|
274
|
+
if not event_name or not event_name.startswith("gen_ai."):
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
body_raw = log_record.get("body", {})
|
|
278
|
+
body = _parse_otlp_body(body_raw)
|
|
279
|
+
|
|
280
|
+
timestamp = log_record.get("timeUnixNano") or log_record.get("observedTimeUnixNano")
|
|
281
|
+
|
|
282
|
+
result = {
|
|
283
|
+
"event_name": event_name,
|
|
284
|
+
"timestamp": timestamp,
|
|
285
|
+
"body": body,
|
|
286
|
+
"attributes": attrs,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
span_id = log_record.get("spanId", "")
|
|
290
|
+
if span_id:
|
|
291
|
+
result["span_id"] = span_id
|
|
292
|
+
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_otlp_any_value(value_obj: dict):
|
|
297
|
+
"""Recursively parse an OTLP AnyValue to native Python types.
|
|
298
|
+
|
|
299
|
+
Handles the full AnyValue union: stringValue, intValue, doubleValue,
|
|
300
|
+
boolValue, kvlistValue (→ dict), arrayValue (→ list), bytesValue.
|
|
301
|
+
"""
|
|
302
|
+
if "stringValue" in value_obj:
|
|
303
|
+
return value_obj["stringValue"]
|
|
304
|
+
if "intValue" in value_obj:
|
|
305
|
+
return int(value_obj["intValue"])
|
|
306
|
+
if "doubleValue" in value_obj:
|
|
307
|
+
return float(value_obj["doubleValue"])
|
|
308
|
+
if "boolValue" in value_obj:
|
|
309
|
+
return value_obj["boolValue"]
|
|
310
|
+
if "kvlistValue" in value_obj:
|
|
311
|
+
kv = value_obj["kvlistValue"]
|
|
312
|
+
return {item.get("key", ""): _parse_otlp_any_value(item.get("value", {})) for item in kv.get("values", [])}
|
|
313
|
+
if "arrayValue" in value_obj:
|
|
314
|
+
arr = value_obj["arrayValue"]
|
|
315
|
+
return [_parse_otlp_any_value(v) for v in arr.get("values", [])]
|
|
316
|
+
if "bytesValue" in value_obj:
|
|
317
|
+
return value_obj["bytesValue"]
|
|
318
|
+
return value_obj
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _parse_otlp_body(body_raw: dict) -> dict | str:
|
|
322
|
+
"""Parse OTLP log record body value.
|
|
323
|
+
|
|
324
|
+
Top-level stringValue bodies are JSON-decoded (Strands-style logs store
|
|
325
|
+
message content as JSON strings). All other AnyValue types are parsed
|
|
326
|
+
recursively via ``_parse_otlp_any_value`` (handles the nested kvlistValue /
|
|
327
|
+
arrayValue structures used by the OpenAI instrumentor).
|
|
328
|
+
"""
|
|
329
|
+
if "stringValue" in body_raw:
|
|
330
|
+
import json
|
|
331
|
+
|
|
332
|
+
raw = body_raw["stringValue"]
|
|
333
|
+
try:
|
|
334
|
+
return json.loads(raw)
|
|
335
|
+
except (json.JSONDecodeError, TypeError):
|
|
336
|
+
return raw
|
|
337
|
+
return _parse_otlp_any_value(body_raw)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Protobuf decoding
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _decode_protobuf_traces(raw: bytes) -> dict:
|
|
346
|
+
"""Decode ExportTraceServiceRequest protobuf to OTLP JSON dict."""
|
|
347
|
+
msg = TraceServiceRequestPB()
|
|
348
|
+
msg.ParseFromString(raw)
|
|
349
|
+
data = MessageToDict(msg, preserving_proto_field_name=False)
|
|
350
|
+
_fix_protobuf_id_fields(data)
|
|
351
|
+
return data
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _decode_protobuf_logs(raw: bytes) -> dict:
|
|
355
|
+
"""Decode ExportLogsServiceRequest protobuf to OTLP JSON dict."""
|
|
356
|
+
msg = LogsServiceRequestPB()
|
|
357
|
+
msg.ParseFromString(raw)
|
|
358
|
+
data = MessageToDict(msg, preserving_proto_field_name=False)
|
|
359
|
+
_fix_protobuf_id_fields(data)
|
|
360
|
+
return data
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _fix_protobuf_id_fields(data) -> None:
|
|
364
|
+
"""Convert base64-encoded bytes fields to hex strings in-place.
|
|
365
|
+
|
|
366
|
+
MessageToDict base64-encodes protobuf bytes fields, but OTLP JSON
|
|
367
|
+
uses hex-encoded strings for traceId, spanId, and parentSpanId.
|
|
368
|
+
"""
|
|
369
|
+
if isinstance(data, dict):
|
|
370
|
+
for key in ("traceId", "spanId", "parentSpanId"):
|
|
371
|
+
if key in data and isinstance(data[key], str):
|
|
372
|
+
try:
|
|
373
|
+
raw = base64.b64decode(data[key])
|
|
374
|
+
data[key] = raw.hex()
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
for value in data.values():
|
|
378
|
+
if isinstance(value, (dict, list)):
|
|
379
|
+
_fix_protobuf_id_fields(value)
|
|
380
|
+
elif isinstance(data, list):
|
|
381
|
+
for item in data:
|
|
382
|
+
if isinstance(item, (dict, list)):
|
|
383
|
+
_fix_protobuf_id_fields(item)
|