mobius-operations-trace-py 1.0.0__tar.gz

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.
Files changed (20) hide show
  1. mobius_operations_trace_py-1.0.0/PKG-INFO +82 -0
  2. mobius_operations_trace_py-1.0.0/README.md +66 -0
  3. mobius_operations_trace_py-1.0.0/pyproject.toml +34 -0
  4. mobius_operations_trace_py-1.0.0/setup.cfg +4 -0
  5. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/__init__.py +32 -0
  6. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/bootstrap.py +58 -0
  7. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/errors.py +34 -0
  8. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/models.py +56 -0
  9. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/pi_client.py +101 -0
  10. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/py.typed +0 -0
  11. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/router.py +65 -0
  12. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/service.py +171 -0
  13. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace/tokens.py +26 -0
  14. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace_py.egg-info/PKG-INFO +82 -0
  15. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace_py.egg-info/SOURCES.txt +18 -0
  16. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace_py.egg-info/dependency_links.txt +1 -0
  17. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace_py.egg-info/requires.txt +8 -0
  18. mobius_operations_trace_py-1.0.0/src/mobius_operations_trace_py.egg-info/top_level.txt +1 -0
  19. mobius_operations_trace_py-1.0.0/tests/test_operations_trace.py +277 -0
  20. mobius_operations_trace_py-1.0.0/tests/test_pi_client.py +130 -0
@@ -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,66 @@
1
+ # mobius-operations-trace-py
2
+
3
+ Request-flow reconstruction and `HUMAN_API` replay for Mobius Python (FastAPI)
4
+ services. Python port of `platform-artifact-lifecycle-lib` +
5
+ `platform-artifact-lifecycle-web` (`MPH-926` / `MPH-928`), verified against
6
+ the Java source (`OperationTraceServiceImpl`, `LifecycleOperationsExceptionHandler`).
7
+
8
+ Relies on action-log rows carrying `spanid` / `parentservicespanid` /
9
+ `requestid` / `httpmethod` / `httpurl` / `requesterid` / `actionsource`, written
10
+ by [mobius-tracer-py](../mobius-tracer-py) +
11
+ [mobius-auditlog-py](../mobius-auditlog-py).
12
+
13
+ ## What it does
14
+
15
+ - `get_request_flow(txnid, requestid)` — queries action logs (either
16
+ identifier alone is enough) and rebuilds the causal call tree (`HUMAN_API`
17
+ entry point first, then each service hop it triggered, in call order) from
18
+ the `spanid` / `parentservicespanid` fields.
19
+ - `replay_request(txnid, requestid)` — finds the originating `HUMAN_API` call,
20
+ exchanges the original requester's identity for a bearer token, and
21
+ re-issues that exact HTTP call (method/URL/body).
22
+
23
+ Both are exposed as FastAPI endpoints:
24
+
25
+ - `GET /v1/operations/request-flows?txnid=...&requestid=...`
26
+ - `POST /v1/operations/request-replays?txnid=...&requestid=...`
27
+
28
+ Error handling mirrors the Java REST layer:
29
+
30
+ - Internal failures (missing identifiers, PI lookup failure, missing
31
+ `HUMAN_API` fields, replay connectivity failure) → HTTP 400 with the same
32
+ envelope `LifecycleOperationsExceptionHandler` emits (`timestamp`, `origin`,
33
+ `errorCode`, `errorMessage`, `httpStatusCode`, `actionsRequired`).
34
+ - A replayed target's own response — even a 4xx/5xx — is forwarded verbatim
35
+ instead of raised, with the outer HTTP status set to match the target's.
36
+
37
+ ## Usage
38
+
39
+ This package has no hard dependency on a specific action-log store or IAM
40
+ client — you supply small adapters:
41
+
42
+ ```python
43
+ from mobius_operations_trace import setup_operations_trace
44
+
45
+ setup_operations_trace(
46
+ app,
47
+ token_provider=my_token_provider, # TokenProvider protocol
48
+ instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
49
+ schema_id="action-log-schema-id",
50
+ )
51
+ ```
52
+
53
+ `instances_list_url` must contain a literal `{schemaId}` placeholder — PI
54
+ takes the schema id as a URL path variable, not a body field (matches
55
+ `lifecycle.operations.trace.instances-list-url` on the Java side). Pass
56
+ `action_log_source` instead of `instances_list_url`/`schema_id` to bypass the
57
+ built-in PI-backed source entirely.
58
+
59
+ `TokenProvider` has a single method, `retrieve_token_from_tenant_id(tenant_id)`
60
+ — used both to authenticate the PI query (with the fixed
61
+ `PI_SERVICE_TENANT_ID`) and to authenticate a replay as the original requester
62
+ (with the `HUMAN_API` row's `requesterid`), matching the Java side's single
63
+ `IamVaultUtils.retrieveTokenFromTenantId` method.
64
+
65
+ See `mobius_operations_trace.pi_client.ActionLogSource` and
66
+ `mobius_operations_trace.tokens.TokenProvider` for the full adapter contracts.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mobius-operations-trace-py"
7
+ version = "1.0.0"
8
+ description = "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."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Mobius Platform" }]
12
+ keywords = ["tracing", "replay", "request-flow", "fastapi", "mobius"]
13
+ dependencies = [
14
+ "pydantic>=2.0",
15
+ "httpx>=0.24",
16
+ "fastapi>=0.100",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=7.4",
22
+ "pytest-asyncio>=0.21",
23
+ "httpx>=0.24",
24
+ ]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ mobius_operations_trace = ["py.typed"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
@@ -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,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/mobius_operations_trace/__init__.py
4
+ src/mobius_operations_trace/bootstrap.py
5
+ src/mobius_operations_trace/errors.py
6
+ src/mobius_operations_trace/models.py
7
+ src/mobius_operations_trace/pi_client.py
8
+ src/mobius_operations_trace/py.typed
9
+ src/mobius_operations_trace/router.py
10
+ src/mobius_operations_trace/service.py
11
+ src/mobius_operations_trace/tokens.py
12
+ src/mobius_operations_trace_py.egg-info/PKG-INFO
13
+ src/mobius_operations_trace_py.egg-info/SOURCES.txt
14
+ src/mobius_operations_trace_py.egg-info/dependency_links.txt
15
+ src/mobius_operations_trace_py.egg-info/requires.txt
16
+ src/mobius_operations_trace_py.egg-info/top_level.txt
17
+ tests/test_operations_trace.py
18
+ tests/test_pi_client.py
@@ -0,0 +1,8 @@
1
+ pydantic>=2.0
2
+ httpx>=0.24
3
+ fastapi>=0.100
4
+
5
+ [dev]
6
+ pytest>=7.4
7
+ pytest-asyncio>=0.21
8
+ httpx>=0.24
@@ -0,0 +1,277 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from fastapi import FastAPI
5
+ from starlette.testclient import TestClient
6
+
7
+ from mobius_operations_trace import (
8
+ OperationTraceError,
9
+ OperationTraceService,
10
+ RequestReplayResponse,
11
+ order_by_span_flow,
12
+ setup_operations_trace,
13
+ )
14
+ from mobius_operations_trace.models import HUMAN_API
15
+
16
+
17
+ def _row(**kw):
18
+ base = {
19
+ "requestid": "req-1",
20
+ "timestamp": 0,
21
+ }
22
+ base.update(kw)
23
+ return base
24
+
25
+
26
+ def test_order_by_span_flow_reconstructs_call_tree():
27
+ rows = [
28
+ _row(spanid="child-2", parentservicespanid="root", actionsource="SERVICE", timestamp=2000),
29
+ _row(spanid="root", parentservicespanid=None, actionsource=HUMAN_API, timestamp=1000),
30
+ _row(spanid="child-1", parentservicespanid="root", actionsource="SERVICE", timestamp=1500),
31
+ _row(spanid="grandchild", parentservicespanid="child-1", actionsource="SERVICE", timestamp=1600),
32
+ ]
33
+
34
+ ordered = order_by_span_flow(rows)
35
+
36
+ assert [row["spanid"] for row in ordered] == ["root", "child-1", "grandchild", "child-2"]
37
+
38
+
39
+ def test_order_by_span_flow_tolerates_numeric_string_timestamps():
40
+ rows = [
41
+ _row(spanid="root", parentservicespanid=None, actionsource=HUMAN_API, timestamp="1000"),
42
+ _row(spanid="child", parentservicespanid="root", actionsource="SERVICE", timestamp="2000"),
43
+ ]
44
+
45
+ ordered = order_by_span_flow(rows)
46
+
47
+ assert [row["spanid"] for row in ordered] == ["root", "child"]
48
+
49
+
50
+ def test_order_by_span_flow_falls_back_to_no_parent_when_no_human_api_root():
51
+ rows = [
52
+ _row(spanid="a", parentservicespanid=None, actionsource="SERVICE", timestamp=1000),
53
+ _row(spanid="b", parentservicespanid="a", actionsource="SERVICE", timestamp=2000),
54
+ ]
55
+
56
+ ordered = order_by_span_flow(rows)
57
+
58
+ assert [row["spanid"] for row in ordered] == ["a", "b"]
59
+
60
+
61
+ def test_order_by_span_flow_appends_orphans_at_end():
62
+ rows = [
63
+ _row(spanid="root", parentservicespanid=None, actionsource=HUMAN_API, timestamp=1000),
64
+ _row(spanid="orphan", parentservicespanid="missing-parent", actionsource="SERVICE", timestamp=2000),
65
+ ]
66
+
67
+ ordered = order_by_span_flow(rows)
68
+
69
+ assert [row["spanid"] for row in ordered] == ["root", "orphan"]
70
+
71
+
72
+ class _FakeActionLogSource:
73
+ def __init__(self, rows):
74
+ self.rows = rows
75
+ self.calls = []
76
+
77
+ def fetch_action_logs(self, txnid, requestid):
78
+ self.calls.append((txnid, requestid))
79
+ return self.rows
80
+
81
+
82
+ class _FakeTokenProvider:
83
+ def retrieve_token_from_tenant_id(self, tenant_id):
84
+ return f"token-for-{tenant_id}"
85
+
86
+
87
+ def test_get_request_flow_requires_at_least_one_identifier():
88
+ service = OperationTraceService(
89
+ action_log_source=_FakeActionLogSource([]), token_provider=_FakeTokenProvider()
90
+ )
91
+ with pytest.raises(OperationTraceError):
92
+ service.get_request_flow(None, None)
93
+
94
+
95
+ def test_get_request_flow_allows_a_single_identifier():
96
+ source = _FakeActionLogSource([_row(spanid="root", actionsource=HUMAN_API)])
97
+ service = OperationTraceService(action_log_source=source, token_provider=_FakeTokenProvider())
98
+
99
+ service.get_request_flow("txn-1", None)
100
+
101
+ assert source.calls == [("txn-1", None)]
102
+
103
+
104
+ def test_get_request_flow_raises_when_no_rows_found():
105
+ service = OperationTraceService(
106
+ action_log_source=_FakeActionLogSource([]), token_provider=_FakeTokenProvider()
107
+ )
108
+ with pytest.raises(OperationTraceError):
109
+ service.get_request_flow("txn-1", "req-1")
110
+
111
+
112
+ def test_replay_request_reissues_the_human_api_call(monkeypatch):
113
+ human_row = _row(
114
+ spanid="root",
115
+ parentservicespanid=None,
116
+ actionsource=HUMAN_API,
117
+ requesterid="user-42",
118
+ httpmethod="POST",
119
+ httpurl="https://svc.internal/v1/widgets",
120
+ requestobject={"name": "widget"},
121
+ )
122
+ service = OperationTraceService(
123
+ action_log_source=_FakeActionLogSource([human_row]),
124
+ token_provider=_FakeTokenProvider(),
125
+ )
126
+
127
+ calls = {}
128
+
129
+ class _FakeResponse:
130
+ status_code = 201
131
+
132
+ def json(self):
133
+ return {"id": "widget-1"}
134
+
135
+ def fake_request(method, url, **kwargs):
136
+ calls["method"] = method
137
+ calls["url"] = url
138
+ calls["kwargs"] = kwargs
139
+ return _FakeResponse()
140
+
141
+ monkeypatch.setattr(service._client, "request", fake_request)
142
+
143
+ result = service.replay_request("txn-1", "req-1")
144
+
145
+ assert isinstance(result, RequestReplayResponse)
146
+ assert result.status_code == 201
147
+ assert result.response == {"id": "widget-1"}
148
+ assert calls["method"] == "POST"
149
+ assert calls["url"] == "https://svc.internal/v1/widgets"
150
+ assert calls["kwargs"]["headers"]["Authorization"] == "Bearer token-for-user-42"
151
+ assert calls["kwargs"]["json"] == {"name": "widget"}
152
+
153
+
154
+ def test_replay_request_forwards_target_error_response_verbatim(monkeypatch):
155
+ """A non-2xx from the replayed target is forwarded as-is, not raised."""
156
+ human_row = _row(
157
+ spanid="root",
158
+ parentservicespanid=None,
159
+ actionsource=HUMAN_API,
160
+ requesterid="user-42",
161
+ httpmethod="GET",
162
+ httpurl="https://svc.internal/v1/widgets/missing",
163
+ )
164
+ service = OperationTraceService(
165
+ action_log_source=_FakeActionLogSource([human_row]),
166
+ token_provider=_FakeTokenProvider(),
167
+ )
168
+
169
+ class _FakeErrorResponse:
170
+ status_code = 404
171
+
172
+ def json(self):
173
+ return {"error": "not found"}
174
+
175
+ monkeypatch.setattr(
176
+ service._client, "request", lambda method, url, **kw: _FakeErrorResponse()
177
+ )
178
+
179
+ result = service.replay_request("txn-1", "req-1")
180
+
181
+ assert result.status_code == 404
182
+ assert result.response == {"error": "not found"}
183
+
184
+
185
+ def test_replay_request_raises_when_human_api_row_missing_url():
186
+ human_row = _row(
187
+ spanid="root",
188
+ parentservicespanid=None,
189
+ actionsource=HUMAN_API,
190
+ requesterid="user-42",
191
+ )
192
+ service = OperationTraceService(
193
+ action_log_source=_FakeActionLogSource([human_row]),
194
+ token_provider=_FakeTokenProvider(),
195
+ )
196
+ with pytest.raises(OperationTraceError):
197
+ service.replay_request("txn-1", "req-1")
198
+
199
+
200
+ def test_router_exposes_request_flows_and_replays():
201
+ human_row = _row(
202
+ spanid="root",
203
+ parentservicespanid=None,
204
+ actionsource=HUMAN_API,
205
+ requesterid="user-42",
206
+ httpmethod="GET",
207
+ httpurl="https://svc.internal/v1/widgets/1",
208
+ )
209
+
210
+ app = FastAPI()
211
+ setup_operations_trace(
212
+ app,
213
+ action_log_source=_FakeActionLogSource([human_row]),
214
+ token_provider=_FakeTokenProvider(),
215
+ )
216
+ client = TestClient(app)
217
+
218
+ flow_resp = client.get(
219
+ "/v1/operations/request-flows", params={"txnid": "txn-1", "requestid": "req-1"}
220
+ )
221
+ assert flow_resp.status_code == 200
222
+ assert flow_resp.json()[0]["spanid"] == "root"
223
+
224
+
225
+ def test_router_returns_java_parity_error_envelope_on_operation_trace_error():
226
+ app = FastAPI()
227
+ setup_operations_trace(
228
+ app,
229
+ action_log_source=_FakeActionLogSource([]),
230
+ token_provider=_FakeTokenProvider(),
231
+ )
232
+ client = TestClient(app)
233
+
234
+ resp = client.get(
235
+ "/v1/operations/request-flows", params={"txnid": "txn-1", "requestid": "req-1"}
236
+ )
237
+ assert resp.status_code == 400
238
+ body = resp.json()
239
+ assert body["origin"] == "mobius-lifecycle-operations"
240
+ assert body["errorCode"] == "LIFECYCLE_ERROR"
241
+ assert body["httpStatusCode"] == "400"
242
+ assert body["actionsRequired"] == []
243
+ assert "timestamp" in body and "errorMessage" in body
244
+
245
+
246
+ def test_router_replay_sets_outer_status_to_target_status(monkeypatch):
247
+ human_row = _row(
248
+ spanid="root",
249
+ parentservicespanid=None,
250
+ actionsource=HUMAN_API,
251
+ requesterid="user-42",
252
+ httpmethod="GET",
253
+ httpurl="https://svc.internal/v1/widgets/missing",
254
+ )
255
+ app = FastAPI()
256
+ service = setup_operations_trace(
257
+ app,
258
+ action_log_source=_FakeActionLogSource([human_row]),
259
+ token_provider=_FakeTokenProvider(),
260
+ )
261
+
262
+ class _FakeErrorResponse:
263
+ status_code = 404
264
+
265
+ def json(self):
266
+ return {"error": "not found"}
267
+
268
+ monkeypatch.setattr(
269
+ service._client, "request", lambda method, url, **kw: _FakeErrorResponse()
270
+ )
271
+ client = TestClient(app)
272
+
273
+ resp = client.post(
274
+ "/v1/operations/request-replays", params={"txnid": "txn-1", "requestid": "req-1"}
275
+ )
276
+ assert resp.status_code == 404
277
+ assert resp.json()["response"] == {"error": "not found"}
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from mobius_operations_trace import OperationTraceError, PI_SERVICE_TENANT_ID
7
+ from mobius_operations_trace.pi_client import PiActionLogSource
8
+
9
+
10
+ class _FakeTokenProvider:
11
+ def __init__(self):
12
+ self.seen_tenant_ids = []
13
+
14
+ def retrieve_token_from_tenant_id(self, tenant_id):
15
+ self.seen_tenant_ids.append(tenant_id)
16
+ return f"token-for-{tenant_id}"
17
+
18
+
19
+ def _client_returning(json_body, status_code=200):
20
+ def handler(request: httpx.Request) -> httpx.Response:
21
+ return httpx.Response(status_code, json=json_body, request=request)
22
+
23
+ return httpx.Client(transport=httpx.MockTransport(handler))
24
+
25
+
26
+ def test_schema_id_is_interpolated_into_the_url_path_not_the_body():
27
+ seen = {}
28
+
29
+ def handler(request: httpx.Request) -> httpx.Response:
30
+ import json as _json
31
+
32
+ seen["url"] = str(request.url)
33
+ seen["payload"] = _json.loads(request.content)
34
+ return httpx.Response(200, json=[])
35
+
36
+ client = httpx.Client(transport=httpx.MockTransport(handler))
37
+ tokens = _FakeTokenProvider()
38
+ source = PiActionLogSource(
39
+ instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
40
+ schema_id="action-log-schema",
41
+ token_provider=tokens,
42
+ client=client,
43
+ )
44
+
45
+ source.fetch_action_logs("txn-1", "req-1")
46
+
47
+ assert seen["url"] == "http://pi-service/v2.0/schemas/action-log-schema/instances/list"
48
+ assert seen["payload"] == {
49
+ "dbType": "TIDB",
50
+ "filter": {"txnid": "txn-1", "requestid": "req-1"},
51
+ }
52
+ assert "schemaId" not in seen["payload"]
53
+
54
+
55
+ def test_filter_only_includes_non_blank_identifiers():
56
+ captured = {}
57
+
58
+ def handler(request: httpx.Request) -> httpx.Response:
59
+ import json as _json
60
+
61
+ captured["payload"] = _json.loads(request.content)
62
+ return httpx.Response(200, json=[])
63
+
64
+ client = httpx.Client(transport=httpx.MockTransport(handler))
65
+ source = PiActionLogSource(
66
+ instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
67
+ schema_id="action-log-schema",
68
+ token_provider=_FakeTokenProvider(),
69
+ client=client,
70
+ )
71
+
72
+ source.fetch_action_logs("txn-1", None)
73
+
74
+ assert captured["payload"]["filter"] == {"txnid": "txn-1"}
75
+
76
+
77
+ def test_uses_service_tenant_id_for_authentication_token():
78
+ tokens = _FakeTokenProvider()
79
+ client = _client_returning([])
80
+ source = PiActionLogSource(
81
+ instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
82
+ schema_id="action-log-schema",
83
+ token_provider=tokens,
84
+ client=client,
85
+ )
86
+
87
+ source.fetch_action_logs("txn-1", "req-1")
88
+
89
+ assert tokens.seen_tenant_ids == [PI_SERVICE_TENANT_ID]
90
+
91
+
92
+ def test_bare_array_response_is_returned_directly():
93
+ rows = [{"spanid": "root"}]
94
+ client = _client_returning(rows)
95
+ source = PiActionLogSource(
96
+ instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
97
+ schema_id="action-log-schema",
98
+ token_provider=_FakeTokenProvider(),
99
+ client=client,
100
+ )
101
+
102
+ result = source.fetch_action_logs("txn-1", "req-1")
103
+
104
+ assert result == rows
105
+
106
+
107
+ def test_non_array_response_raises_operation_trace_error():
108
+ client = _client_returning({"data": [{"spanid": "root"}]})
109
+ source = PiActionLogSource(
110
+ instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
111
+ schema_id="action-log-schema",
112
+ token_provider=_FakeTokenProvider(),
113
+ client=client,
114
+ )
115
+
116
+ with pytest.raises(OperationTraceError):
117
+ source.fetch_action_logs("txn-1", "req-1")
118
+
119
+
120
+ def test_http_error_raises_generic_operation_trace_error():
121
+ client = _client_returning({"error": "boom"}, status_code=500)
122
+ source = PiActionLogSource(
123
+ instances_list_url="http://pi-service/v2.0/schemas/{schemaId}/instances/list",
124
+ schema_id="action-log-schema",
125
+ token_provider=_FakeTokenProvider(),
126
+ client=client,
127
+ )
128
+
129
+ with pytest.raises(OperationTraceError):
130
+ source.fetch_action_logs("txn-1", "req-1")