Skip to content

Commit 5ce94b9

Browse files
committed
🐛 fix(Traces): Add proper tracing
1 parent a839e8a commit 5ce94b9

4 files changed

Lines changed: 220 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ jobs:
3636
python-checks:
3737
name: Linting and Formatting
3838
runs-on: ubuntu-latest
39+
# Skip on main push since PR already validated
40+
if: github.event_name == 'pull_request'
3941
steps:
4042
- uses: actions/checkout@v6
4143

@@ -58,9 +60,11 @@ jobs:
5860
- name: Run Ruff Formatting Check
5961
run: uv run ruff format --check .
6062

61-
test:
63+
# Full compatibility matrix - only on PRs to main
64+
test-matrix:
6265
name: Tests (Python ${{ matrix.python-version }}, ${{ matrix.os }})
6366
runs-on: ${{ matrix.os }}
67+
if: github.event_name == 'pull_request' && github.base_ref == 'main'
6468
strategy:
6569
fail-fast: false
6670
matrix:
@@ -87,6 +91,37 @@ jobs:
8791

8892
- name: Upload coverage to Codecov
8993
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
94+
uses: codecov/codecov-action@v5
95+
with:
96+
token: ${{ secrets.CODECOV_TOKEN }}
97+
files: reports/coverage.xml
98+
fail_ci_if_error: true
99+
100+
# Quick test - for PRs to non-main branches
101+
test-quick:
102+
name: Tests (Quick)
103+
runs-on: ubuntu-latest
104+
if: github.event_name == 'pull_request' && github.base_ref != 'main'
105+
steps:
106+
- uses: actions/checkout@v6
107+
108+
- name: Install uv
109+
uses: astral-sh/setup-uv@v7
110+
with:
111+
enable-cache: true
112+
113+
- name: Set up Python
114+
uses: actions/setup-python@v6
115+
with:
116+
python-version: '3.11'
117+
118+
- name: Install dependencies
119+
run: uv sync --group dev
120+
121+
- name: Run tests with coverage
122+
run: uv run pytest --cov --cov-report=xml:reports/coverage.xml -m "not google_adk"
123+
124+
- name: Upload coverage to Codecov
90125
uses: codecov/codecov-action@v5
91126
with:
92127
token: ${{ secrets.CODECOV_TOKEN }}

hackagent/attacks/techniques/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ def __init__(
105105
# This allows subclass to merge with its own defaults first
106106
self.config = config
107107

108-
# Run setup
109-
self.run_id = self.config.get("run_id")
108+
# Run setup - check both "run_id" and "_run_id" for backwards compatibility
109+
# The orchestrator passes "_run_id" while direct usage may use "run_id"
110+
self.run_id = self.config.get("_run_id") or self.config.get("run_id")
110111
self.run_dir = self.config.get("output_dir", "./logs/runs")
111112

112113
# Tracking

hackagent/router/router.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,9 +1082,170 @@ def route_with_tracking(
10821082
f"⚠️ Result creation failed: status={result_response.status_code}"
10831083
)
10841084

1085+
# Create traces for this result if we have a result_id
1086+
if result_id:
1087+
self._create_traces_for_result(
1088+
result_id=result_id,
1089+
client=client,
1090+
request_data=request_data,
1091+
response=response,
1092+
)
1093+
10851094
except Exception as e:
10861095
logger.error(f"❌ Failed to create Result record: {e}", exc_info=True)
10871096
result_id = None
10881097

10891098
# Return both response and result_id for tracking
10901099
return {"response": response, "result_id": result_id}
1100+
1101+
def _create_traces_for_result(
1102+
self,
1103+
result_id: str,
1104+
client,
1105+
request_data: Dict[str, Any],
1106+
response: Any,
1107+
) -> None:
1108+
"""
1109+
Create trace records for a result capturing the agent execution details.
1110+
1111+
Args:
1112+
result_id: The UUID of the result to attach traces to
1113+
client: Authenticated client for API calls
1114+
request_data: The original request data sent to the agent
1115+
response: The response from the agent
1116+
"""
1117+
from ..api.result import result_trace_create
1118+
from ..models import TraceRequest, StepTypeEnum
1119+
1120+
sequence = 0
1121+
1122+
try:
1123+
result_uuid = UUID(result_id)
1124+
1125+
# Trace 1: Capture the user input/request
1126+
sequence += 1
1127+
input_trace = TraceRequest(
1128+
sequence=sequence,
1129+
step_type=StepTypeEnum.OTHER,
1130+
content={
1131+
"step_name": "User Request",
1132+
"messages": request_data.get("messages", []),
1133+
"prompt": request_data.get("prompt", ""),
1134+
},
1135+
)
1136+
result_trace_create.sync_detailed(
1137+
id=result_uuid,
1138+
client=client,
1139+
body=input_trace,
1140+
)
1141+
logger.debug(f"Created input trace for result {result_id}")
1142+
1143+
# Trace 2: Capture tool calls if present
1144+
if hasattr(response, "tool_calls") and response.tool_calls:
1145+
for tool_call in response.tool_calls:
1146+
sequence += 1
1147+
tool_trace = TraceRequest(
1148+
sequence=sequence,
1149+
step_type=StepTypeEnum.TOOL_CALL,
1150+
content={
1151+
"step_name": "Tool Call",
1152+
"tool_name": tool_call.get("function", {}).get(
1153+
"name", "unknown"
1154+
)
1155+
if isinstance(tool_call, dict)
1156+
else getattr(
1157+
getattr(tool_call, "function", None), "name", "unknown"
1158+
),
1159+
"tool_id": tool_call.get("id", "")
1160+
if isinstance(tool_call, dict)
1161+
else getattr(tool_call, "id", ""),
1162+
"arguments": tool_call.get("function", {}).get(
1163+
"arguments", ""
1164+
)
1165+
if isinstance(tool_call, dict)
1166+
else getattr(
1167+
getattr(tool_call, "function", None), "arguments", ""
1168+
),
1169+
},
1170+
)
1171+
result_trace_create.sync_detailed(
1172+
id=result_uuid,
1173+
client=client,
1174+
body=tool_trace,
1175+
)
1176+
logger.debug(f"Created {len(response.tool_calls)} tool call traces")
1177+
1178+
# Check dict response for tool_calls
1179+
elif isinstance(response, dict) and response.get("tool_calls"):
1180+
for tool_call in response["tool_calls"]:
1181+
sequence += 1
1182+
tool_trace = TraceRequest(
1183+
sequence=sequence,
1184+
step_type=StepTypeEnum.TOOL_CALL,
1185+
content={
1186+
"step_name": "Tool Call",
1187+
"tool_name": tool_call.get("function", {}).get(
1188+
"name", "unknown"
1189+
),
1190+
"tool_id": tool_call.get("id", ""),
1191+
"arguments": tool_call.get("function", {}).get(
1192+
"arguments", ""
1193+
),
1194+
},
1195+
)
1196+
result_trace_create.sync_detailed(
1197+
id=result_uuid,
1198+
client=client,
1199+
body=tool_trace,
1200+
)
1201+
logger.debug(f"Created {len(response['tool_calls'])} tool call traces")
1202+
1203+
# Trace 3: Capture the agent response
1204+
sequence += 1
1205+
response_content = ""
1206+
if hasattr(response, "choices") and response.choices:
1207+
choice = response.choices[0]
1208+
if hasattr(choice, "message") and choice.message:
1209+
response_content = getattr(choice.message, "content", "") or ""
1210+
elif isinstance(response, dict):
1211+
if "choices" in response and response["choices"]:
1212+
response_content = (
1213+
response["choices"][0].get("message", {}).get("content", "")
1214+
)
1215+
elif "generated_text" in response:
1216+
response_content = response["generated_text"]
1217+
elif "content" in response:
1218+
response_content = response["content"]
1219+
1220+
response_trace = TraceRequest(
1221+
sequence=sequence,
1222+
step_type=StepTypeEnum.AGENT_RESPONSE_CHUNK,
1223+
content={
1224+
"step_name": "Agent Response",
1225+
"response": response_content[:5000]
1226+
if response_content
1227+
else "", # Truncate large responses
1228+
"finish_reason": self._extract_finish_reason(response),
1229+
},
1230+
)
1231+
result_trace_create.sync_detailed(
1232+
id=result_uuid,
1233+
client=client,
1234+
body=response_trace,
1235+
)
1236+
logger.info(f"✅ Created {sequence} traces for result {result_id}")
1237+
1238+
except Exception as e:
1239+
logger.warning(f"Failed to create traces for result {result_id}: {e}")
1240+
1241+
def _extract_finish_reason(self, response: Any) -> str:
1242+
"""Extract finish_reason from response."""
1243+
if hasattr(response, "choices") and response.choices:
1244+
choice = response.choices[0]
1245+
if hasattr(choice, "finish_reason"):
1246+
return choice.finish_reason or "unknown"
1247+
elif isinstance(response, dict) and "choices" in response:
1248+
choices = response.get("choices", [])
1249+
if choices:
1250+
return choices[0].get("finish_reason", "unknown")
1251+
return "unknown"

hackagent/router/tracking/tracker.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,19 +255,39 @@ def _sanitize_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
255255
Sanitized configuration dictionary
256256
"""
257257
sensitive_keys = {"api_key", "token", "secret", "password", "key"}
258+
# Keys that contain non-serializable objects (like client instances)
259+
skip_keys = {"_client", "client"}
258260

259261
sanitized = {}
260262
for key, value in config.items():
263+
# Skip non-serializable objects
264+
if key in skip_keys:
265+
sanitized[key] = f"<{type(value).__name__}>"
266+
continue
267+
261268
key_lower = key.lower()
262269
if any(sensitive in key_lower for sensitive in sensitive_keys):
263270
sanitized[key] = "***REDACTED***"
264271
elif isinstance(value, dict):
265272
sanitized[key] = self._sanitize_config(value)
273+
elif not self._is_json_serializable(value):
274+
# Skip non-JSON-serializable values
275+
sanitized[key] = f"<{type(value).__name__}>"
266276
else:
267277
sanitized[key] = value
268278

269279
return sanitized
270280

281+
def _is_json_serializable(self, value: Any) -> bool:
282+
"""Check if a value is JSON serializable."""
283+
import json
284+
285+
try:
286+
json.dumps(value)
287+
return True
288+
except (TypeError, ValueError):
289+
return False
290+
271291
def _handle_step_error(self, step_name: str, error_message: str) -> None:
272292
"""
273293
Update backend with step error information.

0 commit comments

Comments
 (0)