mobius-operations-trace-py 1.0.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.
- mobius_operations_trace/__init__.py +32 -0
- mobius_operations_trace/bootstrap.py +58 -0
- mobius_operations_trace/errors.py +34 -0
- mobius_operations_trace/models.py +56 -0
- mobius_operations_trace/pi_client.py +101 -0
- mobius_operations_trace/py.typed +0 -0
- mobius_operations_trace/router.py +65 -0
- mobius_operations_trace/service.py +171 -0
- mobius_operations_trace/tokens.py +26 -0
- mobius_operations_trace_py-1.0.0.dist-info/METADATA +82 -0
- mobius_operations_trace_py-1.0.0.dist-info/RECORD +13 -0
- mobius_operations_trace_py-1.0.0.dist-info/WHEEL +5 -0
- mobius_operations_trace_py-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Request-flow reconstruction and HUMAN_API replay for Mobius Python services.
|
|
2
|
+
|
|
3
|
+
Python port of ``platform-artifact-lifecycle-lib`` (core logic) and
|
|
4
|
+
``platform-artifact-lifecycle-web`` (REST exposure): given a ``txnid`` /
|
|
5
|
+
``requestid``, rebuild the causal call tree from action-log
|
|
6
|
+
``spanid`` / ``parentservicespanid`` fields, and replay the originating
|
|
7
|
+
``HUMAN_API`` call.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .bootstrap import setup_operations_trace
|
|
12
|
+
from .errors import DEFAULT_ORIGIN, OperationTraceError, error_envelope
|
|
13
|
+
from .models import PI_SERVICE_TENANT_ID, RequestReplayResponse
|
|
14
|
+
from .pi_client import ActionLogSource, PiActionLogSource
|
|
15
|
+
from .service import OperationTraceService, order_by_span_flow
|
|
16
|
+
from .tokens import TokenProvider
|
|
17
|
+
|
|
18
|
+
__version__ = "1.0.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"setup_operations_trace",
|
|
22
|
+
"OperationTraceService",
|
|
23
|
+
"OperationTraceError",
|
|
24
|
+
"error_envelope",
|
|
25
|
+
"DEFAULT_ORIGIN",
|
|
26
|
+
"RequestReplayResponse",
|
|
27
|
+
"ActionLogSource",
|
|
28
|
+
"PiActionLogSource",
|
|
29
|
+
"TokenProvider",
|
|
30
|
+
"PI_SERVICE_TENANT_ID",
|
|
31
|
+
"order_by_span_flow",
|
|
32
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""One-call setup for FastAPI services."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import DEFAULT_ORIGIN
|
|
9
|
+
from .models import PI_SERVICE_TENANT_ID
|
|
10
|
+
from .pi_client import ActionLogSource, PiActionLogSource
|
|
11
|
+
from .router import router, set_origin, set_service
|
|
12
|
+
from .service import OperationTraceService
|
|
13
|
+
from .tokens import TokenProvider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_operations_trace(
|
|
17
|
+
app: Any,
|
|
18
|
+
*,
|
|
19
|
+
token_provider: TokenProvider,
|
|
20
|
+
action_log_source: Optional[ActionLogSource] = None,
|
|
21
|
+
instances_list_url: Optional[str] = None,
|
|
22
|
+
schema_id: Optional[str] = None,
|
|
23
|
+
service_tenant_id: str = PI_SERVICE_TENANT_ID,
|
|
24
|
+
origin: str = DEFAULT_ORIGIN,
|
|
25
|
+
http_client: Optional[httpx.Client] = None,
|
|
26
|
+
) -> OperationTraceService:
|
|
27
|
+
"""Wire request-flow/replay into a FastAPI ``app``.
|
|
28
|
+
|
|
29
|
+
Supply either ``action_log_source`` directly, or ``instances_list_url`` +
|
|
30
|
+
``schema_id`` to use the default PI-backed source
|
|
31
|
+
(:class:`~mobius_operations_trace.pi_client.PiActionLogSource`).
|
|
32
|
+
|
|
33
|
+
``instances_list_url`` must contain a literal ``{schemaId}`` placeholder,
|
|
34
|
+
matching ``lifecycle.operations.trace.instances-list-url`` on the Java
|
|
35
|
+
side (e.g. ``http://pi-service/v2.0/schemas/{schemaId}/instances/list``).
|
|
36
|
+
"""
|
|
37
|
+
if action_log_source is None:
|
|
38
|
+
if not instances_list_url or not schema_id:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"setup_operations_trace requires either action_log_source, "
|
|
41
|
+
"or both instances_list_url and schema_id"
|
|
42
|
+
)
|
|
43
|
+
action_log_source = PiActionLogSource(
|
|
44
|
+
instances_list_url=instances_list_url,
|
|
45
|
+
schema_id=schema_id,
|
|
46
|
+
token_provider=token_provider,
|
|
47
|
+
service_tenant_id=service_tenant_id,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
service = OperationTraceService(
|
|
51
|
+
action_log_source=action_log_source,
|
|
52
|
+
token_provider=token_provider,
|
|
53
|
+
http_client=http_client,
|
|
54
|
+
)
|
|
55
|
+
set_service(service)
|
|
56
|
+
set_origin(origin)
|
|
57
|
+
app.include_router(router)
|
|
58
|
+
return service
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Errors raised by request-flow reconstruction and replay."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
DEFAULT_ORIGIN = "mobius-lifecycle-operations"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OperationTraceError(Exception):
|
|
11
|
+
"""Raised for any failure in fetching/reconstructing/replaying a request.
|
|
12
|
+
|
|
13
|
+
Mirrors the Java ``LifecycleException`` used for the same failure paths:
|
|
14
|
+
missing identifiers, PI lookup failure, connectivity issues, and missing
|
|
15
|
+
required fields on the ``HUMAN_API`` row. Every one of these maps to a
|
|
16
|
+
generic HTTP 400 on the Java side (``LifecycleOperationsExceptionHandler``)
|
|
17
|
+
— unlike a replay target's own response, which is forwarded verbatim
|
|
18
|
+
rather than raised as an exception.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def error_envelope(exc: OperationTraceError, *, origin: str = DEFAULT_ORIGIN) -> Dict[str, Any]:
|
|
23
|
+
"""Build the exact JSON body ``LifecycleOperationsExceptionHandler`` emits
|
|
24
|
+
for a ``LifecycleException``, for parity with the Java REST layer."""
|
|
25
|
+
return {
|
|
26
|
+
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace(
|
|
27
|
+
"+00:00", "Z"
|
|
28
|
+
),
|
|
29
|
+
"origin": origin,
|
|
30
|
+
"errorCode": "LIFECYCLE_ERROR",
|
|
31
|
+
"errorMessage": str(exc),
|
|
32
|
+
"httpStatusCode": "400",
|
|
33
|
+
"actionsRequired": [],
|
|
34
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Data shapes for request-flow reconstruction and replay.
|
|
2
|
+
|
|
3
|
+
Action-log rows are kept as plain ``dict``s (mirroring the Java
|
|
4
|
+
``List<Map<String, Object>>``) rather than a strict model, since the PI
|
|
5
|
+
action-log schema can carry additional fields beyond the ones this package
|
|
6
|
+
reads.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
# Field names read off an action-log row, verified against
|
|
15
|
+
# OperationTraceServiceImpl.java's own constants — all lowercase, no
|
|
16
|
+
# separators. PI's ingestion/storage layer folds the camelCase
|
|
17
|
+
# ActionLogCreateRequest fields (`spanId`, `httpUrl`, ...) mobius-acl-lib
|
|
18
|
+
# publishes down to these lowercase columns; that folding happens in PI
|
|
19
|
+
# itself, not in either Java repo.
|
|
20
|
+
FIELD_SPAN_ID = "spanid"
|
|
21
|
+
FIELD_PARENT_SPAN_ID = "parentservicespanid"
|
|
22
|
+
FIELD_REQUEST_ID = "requestid"
|
|
23
|
+
FIELD_TIMESTAMP = "timestamp" # epoch milliseconds (System.currentTimeMillis())
|
|
24
|
+
FIELD_HTTP_METHOD = "httpmethod"
|
|
25
|
+
FIELD_HTTP_URL = "httpurl" # flat/top-level, not nested under any envelope
|
|
26
|
+
FIELD_ACTION_SOURCE = "actionsource"
|
|
27
|
+
FIELD_REQUESTER_ID = "requesterid"
|
|
28
|
+
FIELD_REQUEST_OBJECT = "requestobject" # flat/top-level
|
|
29
|
+
|
|
30
|
+
HUMAN_API = "HUMAN_API"
|
|
31
|
+
|
|
32
|
+
ActionLogRow = Dict[str, Any]
|
|
33
|
+
|
|
34
|
+
# HTTP methods that carry a body when replayed.
|
|
35
|
+
BODY_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
36
|
+
|
|
37
|
+
# --- PI "instances list" query contract (OperationTraceServiceImpl.java) ---
|
|
38
|
+
|
|
39
|
+
# Filter keys sent in the PI query body — distinct from any row field name;
|
|
40
|
+
# these are never read back off a returned row.
|
|
41
|
+
FILTER_KEY_TXNID = "txnid"
|
|
42
|
+
FILTER_KEY_REQUESTID = "requestid"
|
|
43
|
+
|
|
44
|
+
REQUEST_KEY_DB_TYPE = "dbType" # the one camelCase key in the PI request body
|
|
45
|
+
REQUEST_KEY_FILTER = "filter"
|
|
46
|
+
DB_TYPE_TIDB = "TIDB"
|
|
47
|
+
|
|
48
|
+
# Fixed platform service-tenant id used to authenticate the PI query call —
|
|
49
|
+
# the same shared constant mobius-auditlog-py defaults to for its tenant id.
|
|
50
|
+
PI_SERVICE_TENANT_ID = "2cf76e5f-26ad-4f2c-bccc-f4bc1e7bfb64"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RequestReplayResponse(BaseModel):
|
|
54
|
+
request_id: Optional[str] = None
|
|
55
|
+
status_code: Optional[int] = None
|
|
56
|
+
response: Any = None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Action-log lookup.
|
|
2
|
+
|
|
3
|
+
Defines the ``ActionLogSource`` contract (so tests / alternate stores can
|
|
4
|
+
substitute their own) and a default HTTP implementation that queries the PI
|
|
5
|
+
"instances list" API for the action-log schema, mirroring
|
|
6
|
+
``OperationTraceServiceImpl.fetchActionLogs`` on the Java side.
|
|
7
|
+
|
|
8
|
+
Verified contract (see ``OperationTraceServiceImpl.java`` / its test fixture):
|
|
9
|
+
- ``schemaId`` is a URL *path* variable — the configured
|
|
10
|
+
``instances-list-url`` contains a literal ``{schemaId}`` placeholder that
|
|
11
|
+
gets expanded, e.g. ``http://pi-service/v2.0/schemas/{schemaId}/instances/list``.
|
|
12
|
+
- Request body is exactly ``{"dbType": "TIDB", "filter": {...}}`` — no
|
|
13
|
+
``schemaId`` in the body. ``filter`` only includes whichever of
|
|
14
|
+
``txnid`` / ``requestid`` are non-blank.
|
|
15
|
+
- Response is a **bare JSON array** of row dicts, never wrapped in an
|
|
16
|
+
envelope. An empty array means "not found".
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Dict, List, Optional, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .errors import OperationTraceError
|
|
26
|
+
from .models import (
|
|
27
|
+
DB_TYPE_TIDB,
|
|
28
|
+
FILTER_KEY_REQUESTID,
|
|
29
|
+
FILTER_KEY_TXNID,
|
|
30
|
+
PI_SERVICE_TENANT_ID,
|
|
31
|
+
REQUEST_KEY_DB_TYPE,
|
|
32
|
+
REQUEST_KEY_FILTER,
|
|
33
|
+
ActionLogRow,
|
|
34
|
+
)
|
|
35
|
+
from .tokens import TokenProvider
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class ActionLogSource(Protocol):
|
|
42
|
+
def fetch_action_logs(
|
|
43
|
+
self, txnid: Optional[str], requestid: Optional[str]
|
|
44
|
+
) -> List[ActionLogRow]:
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PiActionLogSource:
|
|
49
|
+
"""Default ``ActionLogSource``: queries PI over HTTP."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
instances_list_url: str,
|
|
55
|
+
schema_id: str,
|
|
56
|
+
token_provider: TokenProvider,
|
|
57
|
+
service_tenant_id: str = PI_SERVICE_TENANT_ID,
|
|
58
|
+
db_type: str = DB_TYPE_TIDB,
|
|
59
|
+
client: Optional[httpx.Client] = None,
|
|
60
|
+
timeout: float = 10.0,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._url = instances_list_url.replace("{schemaId}", schema_id)
|
|
63
|
+
self._tokens = token_provider
|
|
64
|
+
self._service_tenant_id = service_tenant_id
|
|
65
|
+
self._db_type = db_type
|
|
66
|
+
self._client = client or httpx.Client(timeout=timeout)
|
|
67
|
+
|
|
68
|
+
def fetch_action_logs(
|
|
69
|
+
self, txnid: Optional[str], requestid: Optional[str]
|
|
70
|
+
) -> List[ActionLogRow]:
|
|
71
|
+
filter_: Dict[str, str] = {}
|
|
72
|
+
if txnid:
|
|
73
|
+
filter_[FILTER_KEY_TXNID] = txnid
|
|
74
|
+
if requestid:
|
|
75
|
+
filter_[FILTER_KEY_REQUESTID] = requestid
|
|
76
|
+
|
|
77
|
+
payload = {REQUEST_KEY_DB_TYPE: self._db_type, REQUEST_KEY_FILTER: filter_}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
token = self._tokens.retrieve_token_from_tenant_id(self._service_tenant_id)
|
|
81
|
+
resp = self._client.post(
|
|
82
|
+
self._url,
|
|
83
|
+
json=payload,
|
|
84
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
except httpx.HTTPError as exc:
|
|
88
|
+
log.error(
|
|
89
|
+
"PI action-log lookup failed for txnid=%s requestid=%s", txnid, requestid,
|
|
90
|
+
exc_info=True,
|
|
91
|
+
)
|
|
92
|
+
raise OperationTraceError("Unable to fetch request action logs") from exc
|
|
93
|
+
|
|
94
|
+
body = resp.json()
|
|
95
|
+
if not isinstance(body, list):
|
|
96
|
+
log.error(
|
|
97
|
+
"Unexpected PI response shape for txnid=%s requestid=%s: %r",
|
|
98
|
+
txnid, requestid, type(body),
|
|
99
|
+
)
|
|
100
|
+
raise OperationTraceError("Unable to fetch request action logs")
|
|
101
|
+
return body
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""FastAPI REST exposure.
|
|
2
|
+
|
|
3
|
+
Python port of ``IOperationController`` / ``OperationControllerImpl``:
|
|
4
|
+
|
|
5
|
+
- ``GET /v1/operations/request-flows?txnid=...&requestid=...``
|
|
6
|
+
- ``POST /v1/operations/request-replays?txnid=...&requestid=...``
|
|
7
|
+
|
|
8
|
+
Both delegate straight to the configured :class:`OperationTraceService`.
|
|
9
|
+
|
|
10
|
+
Error handling mirrors ``LifecycleOperationsExceptionHandler``: any
|
|
11
|
+
:class:`OperationTraceError` (missing identifiers, PI lookup failure, missing
|
|
12
|
+
``HUMAN_API`` fields, replay connectivity failure) maps to a generic HTTP 400
|
|
13
|
+
with the Java error envelope. A replayed target's own response — even a 4xx/5xx
|
|
14
|
+
— is forwarded verbatim instead, with the outer HTTP status set to match.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter
|
|
21
|
+
from fastapi.responses import JSONResponse
|
|
22
|
+
|
|
23
|
+
from .errors import DEFAULT_ORIGIN, OperationTraceError, error_envelope
|
|
24
|
+
from .service import OperationTraceService
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/v1/operations", tags=["operations"])
|
|
27
|
+
|
|
28
|
+
_default_service: Optional[OperationTraceService] = None
|
|
29
|
+
_origin: str = DEFAULT_ORIGIN
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_service(service: Optional[OperationTraceService]) -> None:
|
|
33
|
+
global _default_service
|
|
34
|
+
_default_service = service
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_origin(origin: str) -> None:
|
|
38
|
+
global _origin
|
|
39
|
+
_origin = origin
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_service() -> OperationTraceService:
|
|
43
|
+
if _default_service is None:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
"mobius-operations-trace: no OperationTraceService configured. "
|
|
46
|
+
"Call setup_operations_trace(app, ...) first."
|
|
47
|
+
)
|
|
48
|
+
return _default_service
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/request-flows", response_model=None)
|
|
52
|
+
def get_request_flows(txnid: Optional[str] = None, requestid: Optional[str] = None):
|
|
53
|
+
try:
|
|
54
|
+
return get_service().get_request_flow(txnid, requestid)
|
|
55
|
+
except OperationTraceError as exc:
|
|
56
|
+
return JSONResponse(status_code=400, content=error_envelope(exc, origin=_origin))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/request-replays", response_model=None)
|
|
60
|
+
def post_request_replays(txnid: Optional[str] = None, requestid: Optional[str] = None):
|
|
61
|
+
try:
|
|
62
|
+
result = get_service().replay_request(txnid, requestid)
|
|
63
|
+
except OperationTraceError as exc:
|
|
64
|
+
return JSONResponse(status_code=400, content=error_envelope(exc, origin=_origin))
|
|
65
|
+
return JSONResponse(status_code=result.status_code or 200, content=result.model_dump())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Request-flow reconstruction and replay.
|
|
2
|
+
|
|
3
|
+
Python port of ``OperationTraceServiceImpl``:
|
|
4
|
+
|
|
5
|
+
- :func:`order_by_span_flow` rebuilds the causal call tree from the flat list
|
|
6
|
+
of action-log rows using ``spanid`` / ``parentservicespanid`` — the
|
|
7
|
+
``HUMAN_API`` row(s) first, then each service hop in call order, orphans
|
|
8
|
+
appended at the end.
|
|
9
|
+
- :class:`OperationTraceService` wraps that with the PI lookup
|
|
10
|
+
(``get_request_flow``) and re-issuing the original ``HUMAN_API`` call as the
|
|
11
|
+
original requester (``replay_request``).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .errors import OperationTraceError
|
|
21
|
+
from .models import (
|
|
22
|
+
BODY_METHODS,
|
|
23
|
+
FIELD_ACTION_SOURCE,
|
|
24
|
+
FIELD_HTTP_METHOD,
|
|
25
|
+
FIELD_HTTP_URL,
|
|
26
|
+
FIELD_PARENT_SPAN_ID,
|
|
27
|
+
FIELD_REQUEST_OBJECT,
|
|
28
|
+
FIELD_REQUESTER_ID,
|
|
29
|
+
FIELD_SPAN_ID,
|
|
30
|
+
FIELD_TIMESTAMP,
|
|
31
|
+
HUMAN_API,
|
|
32
|
+
ActionLogRow,
|
|
33
|
+
RequestReplayResponse,
|
|
34
|
+
)
|
|
35
|
+
from .pi_client import ActionLogSource
|
|
36
|
+
from .tokens import TokenProvider
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _timestamp_ms(row: ActionLogRow) -> int:
|
|
42
|
+
"""``timestamp`` is epoch milliseconds (``System.currentTimeMillis()`` on
|
|
43
|
+
the write side); tolerate it arriving as a number or a numeric string."""
|
|
44
|
+
value = row.get(FIELD_TIMESTAMP)
|
|
45
|
+
if isinstance(value, (int, float)):
|
|
46
|
+
return int(value)
|
|
47
|
+
try:
|
|
48
|
+
return int(value) # type: ignore[arg-type]
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def order_by_span_flow(rows: List[ActionLogRow]) -> List[ActionLogRow]:
|
|
54
|
+
indexed = list(enumerate(rows))
|
|
55
|
+
|
|
56
|
+
children_by_parent: Dict[Any, List[tuple]] = {}
|
|
57
|
+
for idx, row in indexed:
|
|
58
|
+
parent = row.get(FIELD_PARENT_SPAN_ID)
|
|
59
|
+
children_by_parent.setdefault(parent, []).append((idx, row))
|
|
60
|
+
for children in children_by_parent.values():
|
|
61
|
+
children.sort(key=lambda pair: (_timestamp_ms(pair[1]), pair[0]))
|
|
62
|
+
|
|
63
|
+
roots = [
|
|
64
|
+
(idx, row)
|
|
65
|
+
for idx, row in indexed
|
|
66
|
+
if str(row.get(FIELD_ACTION_SOURCE, "")).upper() == HUMAN_API
|
|
67
|
+
]
|
|
68
|
+
if not roots:
|
|
69
|
+
roots = [(idx, row) for idx, row in indexed if not row.get(FIELD_PARENT_SPAN_ID)]
|
|
70
|
+
roots.sort(key=lambda pair: (_timestamp_ms(pair[1]), pair[0]))
|
|
71
|
+
|
|
72
|
+
ordered: List[ActionLogRow] = []
|
|
73
|
+
visited: set = set()
|
|
74
|
+
|
|
75
|
+
def visit(idx: int, row: ActionLogRow) -> None:
|
|
76
|
+
if idx in visited:
|
|
77
|
+
return
|
|
78
|
+
visited.add(idx)
|
|
79
|
+
ordered.append(row)
|
|
80
|
+
for child_idx, child_row in children_by_parent.get(row.get(FIELD_SPAN_ID), []):
|
|
81
|
+
visit(child_idx, child_row)
|
|
82
|
+
|
|
83
|
+
for idx, row in roots:
|
|
84
|
+
visit(idx, row)
|
|
85
|
+
|
|
86
|
+
orphans = [(idx, row) for idx, row in indexed if idx not in visited]
|
|
87
|
+
orphans.sort(key=lambda pair: (_timestamp_ms(pair[1]), pair[0]))
|
|
88
|
+
ordered.extend(row for _, row in orphans)
|
|
89
|
+
return ordered
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OperationTraceService:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
action_log_source: ActionLogSource,
|
|
97
|
+
token_provider: TokenProvider,
|
|
98
|
+
http_client: Optional[httpx.Client] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._logs = action_log_source
|
|
101
|
+
self._tokens = token_provider
|
|
102
|
+
self._client = http_client or httpx.Client(timeout=30.0)
|
|
103
|
+
|
|
104
|
+
def get_request_flow(
|
|
105
|
+
self, txnid: Optional[str], requestid: Optional[str]
|
|
106
|
+
) -> List[ActionLogRow]:
|
|
107
|
+
# PI's filter accepts either identifier alone; only reject when both
|
|
108
|
+
# are blank (mirrors OperationTraceServiceImpl's own filter-building).
|
|
109
|
+
if not txnid and not requestid:
|
|
110
|
+
raise OperationTraceError("At least one of txnid or requestid is required")
|
|
111
|
+
|
|
112
|
+
rows = self._logs.fetch_action_logs(txnid, requestid)
|
|
113
|
+
if not rows:
|
|
114
|
+
raise OperationTraceError(
|
|
115
|
+
f"No action logs found for txnid={txnid}, requestid={requestid}"
|
|
116
|
+
)
|
|
117
|
+
return order_by_span_flow(rows)
|
|
118
|
+
|
|
119
|
+
def replay_request(
|
|
120
|
+
self, txnid: Optional[str], requestid: Optional[str]
|
|
121
|
+
) -> RequestReplayResponse:
|
|
122
|
+
ordered = self.get_request_flow(txnid, requestid)
|
|
123
|
+
human_api_row = next(
|
|
124
|
+
(
|
|
125
|
+
row
|
|
126
|
+
for row in ordered
|
|
127
|
+
if str(row.get(FIELD_ACTION_SOURCE, "")).upper() == HUMAN_API
|
|
128
|
+
),
|
|
129
|
+
None,
|
|
130
|
+
)
|
|
131
|
+
if human_api_row is None:
|
|
132
|
+
raise OperationTraceError(
|
|
133
|
+
f"No HUMAN_API entry point found for txnid={txnid}, requestid={requestid}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
requester_id = human_api_row.get(FIELD_REQUESTER_ID)
|
|
137
|
+
if not requester_id:
|
|
138
|
+
raise OperationTraceError("HUMAN_API action-log row is missing requesterid")
|
|
139
|
+
|
|
140
|
+
method = human_api_row.get(FIELD_HTTP_METHOD)
|
|
141
|
+
url = human_api_row.get(FIELD_HTTP_URL)
|
|
142
|
+
if not method or not url:
|
|
143
|
+
raise OperationTraceError("HUMAN_API action-log row is missing httpmethod/httpurl")
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
token = self._tokens.retrieve_token_from_tenant_id(requester_id)
|
|
147
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a descriptive trace error
|
|
148
|
+
raise OperationTraceError(
|
|
149
|
+
f"Failed to exchange token for requester {requester_id}"
|
|
150
|
+
) from exc
|
|
151
|
+
|
|
152
|
+
kwargs: Dict[str, Any] = {"headers": {"Authorization": f"Bearer {token}"}}
|
|
153
|
+
if method.upper() in BODY_METHODS:
|
|
154
|
+
kwargs["json"] = human_api_row.get(FIELD_REQUEST_OBJECT)
|
|
155
|
+
|
|
156
|
+
# A non-2xx response from the replayed call is NOT an error here - it
|
|
157
|
+
# is forwarded verbatim (status + body), mirroring the Java side's
|
|
158
|
+
# RestClientResponseException handling for the target call. Only a
|
|
159
|
+
# genuine transport/connectivity failure raises OperationTraceError.
|
|
160
|
+
try:
|
|
161
|
+
resp = self._client.request(method.upper(), url, **kwargs)
|
|
162
|
+
except httpx.HTTPError as exc:
|
|
163
|
+
log.error("Replay call failed for requestid=%s", requestid, exc_info=True)
|
|
164
|
+
raise OperationTraceError(f"Failed to replay request {requestid}") from exc
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
body = resp.json()
|
|
168
|
+
except ValueError:
|
|
169
|
+
body = resp.text
|
|
170
|
+
|
|
171
|
+
return RequestReplayResponse(request_id=requestid, status_code=resp.status_code, response=body)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Token acquisition contract.
|
|
2
|
+
|
|
3
|
+
This package has no hard dependency on a specific IAM/vault client (mirrors
|
|
4
|
+
the Java side's ``IamVaultUtils``) — each service supplies a small adapter.
|
|
5
|
+
|
|
6
|
+
Verified against the Java source: there is exactly one method,
|
|
7
|
+
``retrieveTokenFromTenantId(tenantId)``, used identically for both the fixed
|
|
8
|
+
service tenant (to authenticate the PI query) and a row's ``requesterid``
|
|
9
|
+
(to authenticate a replay as the original requester) — no other parameters
|
|
10
|
+
(requester type, separate tenant id) are involved.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class TokenProvider(Protocol):
|
|
19
|
+
def retrieve_token_from_tenant_id(self, tenant_id: str) -> str:
|
|
20
|
+
"""Bearer token for the given tenant id.
|
|
21
|
+
|
|
22
|
+
Called with :data:`~mobius_operations_trace.models.PI_SERVICE_TENANT_ID`
|
|
23
|
+
to authenticate the PI query, and with a ``HUMAN_API`` row's
|
|
24
|
+
``requesterid`` to authenticate a replay as that requester.
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mobius-operations-trace-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Request-flow reconstruction and HUMAN_API replay for Mobius Python (FastAPI) services, built on the span/parent-span action-log fields written by mobius-tracer-py and mobius-auditlog-py.
|
|
5
|
+
Author: Mobius Platform
|
|
6
|
+
Keywords: tracing,replay,request-flow,fastapi,mobius
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: httpx>=0.24
|
|
11
|
+
Requires-Dist: fastapi>=0.100
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
15
|
+
Requires-Dist: httpx>=0.24; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# mobius-operations-trace-py
|
|
18
|
+
|
|
19
|
+
Request-flow reconstruction and `HUMAN_API` replay for Mobius Python (FastAPI)
|
|
20
|
+
services. Python port of `platform-artifact-lifecycle-lib` +
|
|
21
|
+
`platform-artifact-lifecycle-web` (`MPH-926` / `MPH-928`), verified against
|
|
22
|
+
the Java source (`OperationTraceServiceImpl`, `LifecycleOperationsExceptionHandler`).
|
|
23
|
+
|
|
24
|
+
Relies on action-log rows carrying `spanid` / `parentservicespanid` /
|
|
25
|
+
`requestid` / `httpmethod` / `httpurl` / `requesterid` / `actionsource`, written
|
|
26
|
+
by [mobius-tracer-py](../mobius-tracer-py) +
|
|
27
|
+
[mobius-auditlog-py](../mobius-auditlog-py).
|
|
28
|
+
|
|
29
|
+
## What it does
|
|
30
|
+
|
|
31
|
+
- `get_request_flow(txnid, requestid)` — queries action logs (either
|
|
32
|
+
identifier alone is enough) and rebuilds the causal call tree (`HUMAN_API`
|
|
33
|
+
entry point first, then each service hop it triggered, in call order) from
|
|
34
|
+
the `spanid` / `parentservicespanid` fields.
|
|
35
|
+
- `replay_request(txnid, requestid)` — finds the originating `HUMAN_API` call,
|
|
36
|
+
exchanges the original requester's identity for a bearer token, and
|
|
37
|
+
re-issues that exact HTTP call (method/URL/body).
|
|
38
|
+
|
|
39
|
+
Both are exposed as FastAPI endpoints:
|
|
40
|
+
|
|
41
|
+
- `GET /v1/operations/request-flows?txnid=...&requestid=...`
|
|
42
|
+
- `POST /v1/operations/request-replays?txnid=...&requestid=...`
|
|
43
|
+
|
|
44
|
+
Error handling mirrors the Java REST layer:
|
|
45
|
+
|
|
46
|
+
- Internal failures (missing identifiers, PI lookup failure, missing
|
|
47
|
+
`HUMAN_API` fields, replay connectivity failure) → HTTP 400 with the same
|
|
48
|
+
envelope `LifecycleOperationsExceptionHandler` emits (`timestamp`, `origin`,
|
|
49
|
+
`errorCode`, `errorMessage`, `httpStatusCode`, `actionsRequired`).
|
|
50
|
+
- A replayed target's own response — even a 4xx/5xx — is forwarded verbatim
|
|
51
|
+
instead of raised, with the outer HTTP status set to match the target's.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
This package has no hard dependency on a specific action-log store or IAM
|
|
56
|
+
client — you supply small adapters:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from mobius_operations_trace import setup_operations_trace
|
|
60
|
+
|
|
61
|
+
setup_operations_trace(
|
|
62
|
+
app,
|
|
63
|
+
token_provider=my_token_provider, # TokenProvider protocol
|
|
64
|
+
instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
|
|
65
|
+
schema_id="action-log-schema-id",
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`instances_list_url` must contain a literal `{schemaId}` placeholder — PI
|
|
70
|
+
takes the schema id as a URL path variable, not a body field (matches
|
|
71
|
+
`lifecycle.operations.trace.instances-list-url` on the Java side). Pass
|
|
72
|
+
`action_log_source` instead of `instances_list_url`/`schema_id` to bypass the
|
|
73
|
+
built-in PI-backed source entirely.
|
|
74
|
+
|
|
75
|
+
`TokenProvider` has a single method, `retrieve_token_from_tenant_id(tenant_id)`
|
|
76
|
+
— used both to authenticate the PI query (with the fixed
|
|
77
|
+
`PI_SERVICE_TENANT_ID`) and to authenticate a replay as the original requester
|
|
78
|
+
(with the `HUMAN_API` row's `requesterid`), matching the Java side's single
|
|
79
|
+
`IamVaultUtils.retrieveTokenFromTenantId` method.
|
|
80
|
+
|
|
81
|
+
See `mobius_operations_trace.pi_client.ActionLogSource` and
|
|
82
|
+
`mobius_operations_trace.tokens.TokenProvider` for the full adapter contracts.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
mobius_operations_trace/__init__.py,sha256=EelPGPoAdaVstMRC5M8QIf0KQ5Rg75XWQsVF5CbL5Qk,1071
|
|
2
|
+
mobius_operations_trace/bootstrap.py,sha256=w7nsu-SoDke3BRrQ_3FmDkiGHlo-fH0MH5ZqkJ2qoTg,2033
|
|
3
|
+
mobius_operations_trace/errors.py,sha256=gSExSOs8YkWeI5UVTOTkZ8roCfuhEHOA2VH4AYvB9rc,1331
|
|
4
|
+
mobius_operations_trace/models.py,sha256=MzPANoI-T--3Q56uWRl2irbp1k1zt6IkpecSdVPYJYQ,2110
|
|
5
|
+
mobius_operations_trace/pi_client.py,sha256=I9jccXr0aPVBJttk3v0GqZwKmKS4Wyq1jKAhCrH13LU,3474
|
|
6
|
+
mobius_operations_trace/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
mobius_operations_trace/router.py,sha256=m1uEtIODU_Tn36xaGbGAiXQaqVkJewhGjHdhri8QjEg,2363
|
|
8
|
+
mobius_operations_trace/service.py,sha256=R3He34l0XCw3M1TIURSV_PnEKaA5UO85ziY293c9_pE,6177
|
|
9
|
+
mobius_operations_trace/tokens.py,sha256=5YsreLRlVcIkdYGmp6Gov86Yhq1uj_EM0SwuGEGYz7c,1028
|
|
10
|
+
mobius_operations_trace_py-1.0.0.dist-info/METADATA,sha256=PxjZRz99HdfT26-vbgGrjCrRFzzgwHnsivk-tJeYqU0,3617
|
|
11
|
+
mobius_operations_trace_py-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
mobius_operations_trace_py-1.0.0.dist-info/top_level.txt,sha256=a-jgLcrAh6V8IKIVnlrbKNewjmKP-iXj9vTvtLaGkW8,24
|
|
13
|
+
mobius_operations_trace_py-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mobius_operations_trace
|