Skip to content

Commit 02d0bb7

Browse files
committed
Applied minor fixes and updated test cases
1 parent 0c6b6e4 commit 02d0bb7

8 files changed

Lines changed: 312 additions & 54 deletions

File tree

lib/idp_common_pkg/idp_common/monitoring/models.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,18 @@ class MonitoringKPIs:
246246
active_config_count: int = 0
247247

248248
def compute_derived(self) -> None:
249-
"""Recompute failure_rate and avg_cost_per_document from raw counts."""
249+
"""Recompute failure_rate and avg_cost_per_document from raw counts.
250+
251+
avg_cost_per_document is always (re-)assigned inside the
252+
``total_documents > 0`` branch so that a subsequent call with
253+
``total_cost = 0`` correctly resets it to 0.0 rather than leaving
254+
a stale non-zero value from a previous computation.
255+
"""
250256
if self.total_documents > 0:
251257
self.failure_rate = self.failed_documents / self.total_documents
252-
if self.total_cost > 0:
253-
self.avg_cost_per_document = self.total_cost / self.total_documents
258+
self.avg_cost_per_document = (
259+
self.total_cost / self.total_documents if self.total_cost > 0 else 0.0
260+
)
254261
else:
255262
self.failure_rate = 0.0
256263
self.avg_cost_per_document = 0.0

lib/idp_common_pkg/idp_common/monitoring/settings_cache.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,22 @@ def get_setting(key: str, default: str = "") -> str:
204204
def get_cloudwatch_log_groups() -> List[str]:
205205
"""Return the CloudWatch log group list from the shared default cache."""
206206
return _default_cache.get_cloudwatch_log_groups()
207+
208+
209+
def reset_default_cache(
210+
ttl_seconds: int = 300,
211+
ssm_client: Optional[Any] = None,
212+
) -> None:
213+
"""
214+
Replace the module-level singleton with a fresh :class:`SettingsCache`.
215+
216+
**For testing only.** Call this in test fixtures or ``teardown`` to
217+
prevent state leakage between test cases that exercise the module-level
218+
helpers (:func:`get_setting`, :func:`get_cloudwatch_log_groups`).
219+
220+
Args:
221+
ttl_seconds: TTL for the new cache (default: 300).
222+
ssm_client: Optional pre-built SSM mock client to inject.
223+
"""
224+
global _default_cache
225+
_default_cache = SettingsCache(ttl_seconds=ttl_seconds, ssm_client=ssm_client)

lib/idp_common_pkg/idp_common/monitoring/stack_utils.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
logger = logging.getLogger(__name__)
3535

3636
# ---------------------------------------------------------------------------
37-
# Module-level lazy boto3 client cache (M-1: avoids creating a new client per call)
37+
# Module-level lazy boto3 client cache
3838
# ---------------------------------------------------------------------------
3939
_cf_client: Optional[Any] = None
4040

@@ -79,16 +79,25 @@ def extract_stack_name_from_arn(execution_arn: str) -> str:
7979

8080
try:
8181
# ARN format: arn:aws:states:<region>:<account>:execution:<state-machine-name>:<exec-id>
82-
parts = execution_arn.split(":")
83-
if len(parts) < 8:
82+
# A valid Step Functions execution ARN has at least 8 colon-separated fields.
83+
# Use rsplit(maxsplit=2) so that execution IDs containing colons are handled
84+
# correctly (the state-machine name is always the second-to-last component).
85+
if execution_arn.count(":") < 7:
86+
# Too few fields — definitely not a valid execution ARN
87+
return ""
88+
parts = execution_arn.rsplit(":", maxsplit=2)
89+
# Expected: ["arn:aws:states:...:execution", "<state-machine-name>", "<exec-id>"]
90+
if len(parts) < 3:
8491
return ""
8592

86-
state_machine_name: str = parts[6]
93+
state_machine_name: str = parts[-2]
8794

88-
# Strip known workflow suffixes to recover the stack name
95+
# Strip known workflow suffixes to recover the stack name.
96+
# Use endswith() so that a suffix appearing in the middle of the name
97+
# is not incorrectly stripped.
8998
for suffix in _STATE_MACHINE_SUFFIXES:
90-
if suffix in state_machine_name:
91-
return state_machine_name.replace(suffix, "")
99+
if state_machine_name.endswith(suffix):
100+
return state_machine_name[: -len(suffix)]
92101

93102
# Fallback: remove the last hyphen-delimited word
94103
# e.g. "MYSTACK-SomeNewSuffix" → "MYSTACK"

lib/idp_common_pkg/idp_common/monitoring/stepfunctions_service.py

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,18 @@
3838
logger = logging.getLogger(__name__)
3939

4040
# ---------------------------------------------------------------------------
41-
# Module-level lazy boto3 client cache (M-1: avoids creating a new client per call)
41+
# Module-level lazy boto3 client cache, keyed by region so that callers passing
42+
# an explicit region always get the correct client, even if a default-region
43+
# client was already initialised first.
4244
# ---------------------------------------------------------------------------
43-
_sf_client: Optional[Any] = None
45+
_sf_clients: Dict[Optional[str], Any] = {}
4446

4547

4648
def _get_sf_client(region: Optional[str] = None) -> Any:
47-
"""Return (and lazily create) a module-level Step Functions boto3 client."""
48-
global _sf_client
49-
if _sf_client is None:
50-
_sf_client = boto3.client("stepfunctions", region_name=region)
51-
return _sf_client
49+
"""Return (and lazily create) a per-region Step Functions boto3 client."""
50+
if region not in _sf_clients:
51+
_sf_clients[region] = boto3.client("stepfunctions", region_name=region)
52+
return _sf_clients[region]
5253

5354

5455
# ---------------------------------------------------------------------------
@@ -171,7 +172,10 @@ def get_execution_data(
171172
result["events"] = events
172173

173174
except sf.exceptions.ExecutionDoesNotExist:
175+
# Use a distinct status so callers can tell "not found" from a
176+
# transient API error (which leaves status as "UNKNOWN").
174177
logger.warning("Execution not found: %s", execution_arn)
178+
result["status"] = "NOT_FOUND"
175179
except Exception as exc: # noqa: BLE001
176180
logger.warning("Failed to get execution data for %s: %s", execution_arn, exc)
177181

@@ -214,7 +218,16 @@ def analyze_execution_timeline(
214218
}``
215219
"""
216220
exec_data = get_execution_data(execution_arn, region=region)
217-
events: List[Dict[str, Any]] = exec_data.get("events", [])[:max_events]
221+
all_events: List[Dict[str, Any]] = exec_data.get("events", [])
222+
if len(all_events) > max_events:
223+
logger.warning(
224+
"Execution %s has %d events; truncating to %d for timeline analysis. "
225+
"Increase max_events to avoid missing failure details.",
226+
execution_arn,
227+
len(all_events),
228+
max_events,
229+
)
230+
events = all_events[:max_events]
218231

219232
timeline: Dict[str, Any] = {
220233
"execution_arn": execution_arn,
@@ -236,38 +249,51 @@ def analyze_execution_timeline(
236249
state_name = event.get("stateEnteredEventDetails", {}).get("name", "")
237250
if state_name:
238251
state_starts[state_name] = timestamp
252+
continue
239253

240-
elif event_type in (
241-
"TaskStateExited",
242-
"TaskFailed",
243-
"TaskTimedOut",
244-
"ExecutionFailed",
245-
):
254+
# --- State-completion and failure events that produce a timeline entry ---
255+
if event_type == "TaskStateExited":
256+
state_name = event.get("stateExitedEventDetails", {}).get("name", "")
257+
is_failure = False
258+
status = "SUCCEEDED"
259+
260+
elif event_type in ("TaskFailed", "TaskTimedOut"):
261+
# Task-level failures do not carry stateExitedEventDetails — look
262+
# back to the most recently entered state to get the name.
263+
state_name = list(state_starts.keys())[-1] if state_starts else "Unknown"
264+
is_failure = True
265+
status = "FAILED"
266+
267+
elif event_type == "ExecutionFailed":
246268
state_name = event.get("stateExitedEventDetails", {}).get("name", "")
247-
if not state_name and event_type == "ExecutionFailed":
269+
if not state_name:
248270
# Attribute failure to the last known entered state
249271
state_name = (
250272
list(state_starts.keys())[-1] if state_starts else "Unknown"
251273
)
274+
is_failure = True
275+
status = "FAILED"
252276

253-
is_failure = "Failed" in event_type or "TimedOut" in event_type
254-
status = "FAILED" if is_failure else "SUCCEEDED"
255-
start = state_starts.get(state_name, "")
256-
duration_ms = _compute_duration_ms(start, timestamp)
277+
else:
278+
# Ignore all other event types (ExecutionStarted, LambdaScheduled, etc.)
279+
continue
257280

258-
states.append(
259-
{
260-
"name": state_name,
261-
"status": status,
262-
"start_time": start,
263-
"end_time": timestamp,
264-
"duration_ms": round(duration_ms, 1),
265-
"is_failure": is_failure,
266-
}
267-
)
281+
start = state_starts.get(state_name, "")
282+
duration_ms = _compute_duration_ms(start, timestamp)
283+
284+
states.append(
285+
{
286+
"name": state_name,
287+
"status": status,
288+
"start_time": start,
289+
"end_time": timestamp,
290+
"duration_ms": round(duration_ms, 1),
291+
"is_failure": is_failure,
292+
}
293+
)
268294

269-
if is_failure and not timeline["failed_state"]:
270-
timeline["failed_state"] = state_name
295+
if is_failure and not timeline["failed_state"]:
296+
timeline["failed_state"] = state_name
271297

272298
timeline["states"] = states
273299
timeline["failure_details"] = extract_failure_details(events)

lib/idp_common_pkg/idp_common/monitoring/xray_service.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@
4343
_DEFAULT_XRAY_LOOKBACK_HOURS: int = 24
4444

4545
# ---------------------------------------------------------------------------
46-
# Module-level lazy boto3 client cache (M-1: avoids creating a new client per call)
46+
# Module-level lazy boto3 client/resource cache
4747
# ---------------------------------------------------------------------------
4848
_xray_client: Optional[Any] = None
49+
_dynamodb_resource: Optional[Any] = None
4950

5051

5152
def _get_xray_client() -> Any:
@@ -56,6 +57,21 @@ def _get_xray_client() -> Any:
5657
return _xray_client
5758

5859

60+
def _get_dynamodb_resource() -> Any:
61+
"""Return (and lazily create) a module-level DynamoDB boto3 resource."""
62+
global _dynamodb_resource
63+
if _dynamodb_resource is None:
64+
_dynamodb_resource = boto3.resource("dynamodb")
65+
return _dynamodb_resource
66+
67+
68+
# Named constant for the tracking table sort key to avoid a magic string
69+
# literal scattered through the code. The tracking table uses a single-item
70+
# partition design where the document metadata record is stored with a sentinel
71+
# sort key value.
72+
_TRACKING_TABLE_SK: str = "none"
73+
74+
5975
# ---------------------------------------------------------------------------
6076
# Public API
6177
# ---------------------------------------------------------------------------
@@ -258,9 +274,11 @@ def _get_trace_id_from_dynamodb(document_id: str) -> Optional[str]:
258274
return None
259275

260276
try:
261-
dynamodb = boto3.resource("dynamodb")
277+
dynamodb = _get_dynamodb_resource()
262278
table = dynamodb.Table(table_name) # type: ignore[attr-defined]
263-
response = table.get_item(Key={"PK": f"doc#{document_id}", "SK": "none"})
279+
response = table.get_item(
280+
Key={"PK": f"doc#{document_id}", "SK": _TRACKING_TABLE_SK}
281+
)
264282
if "Item" in response:
265283
trace_id = response["Item"].get("TraceId")
266284
if trace_id:
@@ -292,7 +310,7 @@ def _get_trace_id_from_xray_annotations(
292310
)
293311
traces = response.get("TraceSummaries", [])
294312
if traces:
295-
# M-3: Sort by ResponseTime descending to get the most recent trace
313+
# Sort by ResponseTime descending to get the most recent trace
296314
# (covers the case where a document has been reprocessed/retried)
297315
traces_sorted = sorted(
298316
traces,

lib/idp_common_pkg/tests/unit/monitoring/test_stack_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020

2121
# ---------------------------------------------------------------------------
22-
# Reset module-level boto3 client cache between tests (M-1 fix)
22+
# Reset module-level boto3 client cache between tests
2323
# ---------------------------------------------------------------------------
2424
@pytest.fixture(autouse=True)
2525
def reset_cf_client():

lib/idp_common_pkg/tests/unit/monitoring/test_stepfunctions_service.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@
1919

2020

2121
# ---------------------------------------------------------------------------
22-
# Reset module-level boto3 client cache between tests (M-1 fix)
22+
# Reset module-level boto3 client cache between tests.
23+
# _sf_clients is a dict keyed by region; clear it between tests.
2324
# ---------------------------------------------------------------------------
2425
@pytest.fixture(autouse=True)
2526
def reset_sf_client():
26-
"""Reset the module-level _sf_client singleton before every test."""
27-
sf_module._sf_client = None
27+
"""Reset the module-level _sf_clients dict before every test."""
28+
sf_module._sf_clients.clear()
2829
yield
29-
sf_module._sf_client = None
30+
sf_module._sf_clients.clear()
3031

3132

3233
# ---------------------------------------------------------------------------
@@ -242,6 +243,22 @@ def test_handles_missing_execution_gracefully(self):
242243
"not found"
243244
)
244245

246+
with patch("idp_common.monitoring.stepfunctions_service.boto3") as mock_boto3:
247+
mock_boto3.client.return_value = sf
248+
result = get_execution_data(_EXEC_ARN)
249+
250+
# ExecutionDoesNotExist → NOT_FOUND (not UNKNOWN, which is for API errors)
251+
assert result["status"] == "NOT_FOUND"
252+
assert result["events"] == []
253+
254+
def test_generic_api_error_returns_unknown_status(self):
255+
"""A transient API error must leave status as UNKNOWN, not NOT_FOUND."""
256+
sf = MagicMock()
257+
sf.exceptions.ExecutionDoesNotExist = type(
258+
"ExecutionDoesNotExist", (Exception,), {}
259+
)
260+
sf.describe_execution.side_effect = Exception("Service unavailable")
261+
245262
with patch("idp_common.monitoring.stepfunctions_service.boto3") as mock_boto3:
246263
mock_boto3.client.return_value = sf
247264
result = get_execution_data(_EXEC_ARN)
@@ -301,6 +318,38 @@ def test_empty_events_returns_empty_states(self):
301318
assert timeline["states"] == []
302319
assert timeline["failed_state"] == ""
303320

321+
def test_task_failed_event_records_state_name_from_prior_entered(self):
322+
"""
323+
TaskFailed does not carry stateExitedEventDetails, so the state
324+
name must be inferred from the last TaskStateEntered event.
325+
"""
326+
events = _make_events(include_failure=True, failure_type="TaskFailed")
327+
sf = _make_sf_client(status="FAILED", events=events)
328+
with patch("idp_common.monitoring.stepfunctions_service.boto3") as mock_boto3:
329+
mock_boto3.client.return_value = sf
330+
timeline = analyze_execution_timeline(_EXEC_ARN)
331+
332+
# The last entered state before TaskFailed is ExtractDocument
333+
assert timeline["failed_state"] == "ExtractDocument"
334+
failed_entries = [s for s in timeline["states"] if s["is_failure"]]
335+
assert len(failed_entries) == 1
336+
assert failed_entries[0]["name"] == "ExtractDocument"
337+
assert failed_entries[0]["name"] != "" # must not be empty string
338+
339+
def test_task_timed_out_event_records_state_name(self):
340+
"""
341+
TaskTimedOut does not carry stateExitedEventDetails either.
342+
"""
343+
events = _make_events(include_failure=True, failure_type="TaskTimedOut")
344+
sf = _make_sf_client(status="FAILED", events=events)
345+
with patch("idp_common.monitoring.stepfunctions_service.boto3") as mock_boto3:
346+
mock_boto3.client.return_value = sf
347+
timeline = analyze_execution_timeline(_EXEC_ARN)
348+
349+
assert timeline["failed_state"] == "ExtractDocument"
350+
failed_entries = [s for s in timeline["states"] if s["is_failure"]]
351+
assert failed_entries[0]["name"] == "ExtractDocument"
352+
304353

305354
# ---------------------------------------------------------------------------
306355
# extract_failure_details — all 6 failure event types

0 commit comments

Comments
 (0)