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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ mobius_operations_trace