Skip to content

Commit 36c2853

Browse files
authored
Merge pull request #66 from luis5tb/auditable-logging
feat: add auditable logging with user_id/org_id correlation
2 parents 418f490 + 68d7b30 commit 36c2853

File tree

14 files changed

+950
-20
lines changed

14 files changed

+950
-20
lines changed

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,21 @@ LOG_FORMAT=json
154154
# detailed - Also logs tool arguments and truncated results (may contain user data)
155155
AGENT_LOGGING_DETAIL=basic
156156

157+
# Audit logging (automatic when LOG_FORMAT=json):
158+
# JSON log records automatically include user_id, org_id, order_id, and
159+
# request_id fields for every log entry. These fields are populated from
160+
# the authenticated user's JWT token and provide a full audit trail of
161+
# data lineage — proving that information shown to the user was authorized
162+
# and retrieved from a verified Red Hat source.
163+
#
164+
# Each agent lifecycle event is tagged with an event_type:
165+
# request_authenticated, agent_run_started, agent_run_completed,
166+
# llm_call_started, llm_call_completed, tool_call_started,
167+
# tool_call_completed, mcp_jwt_forwarded
168+
#
169+
# Tool calls include a data_source field identifying the Red Hat Insights
170+
# MCP tool that retrieved the data. No additional configuration is required.
171+
157172
# -----------------------------------------------------------------------------
158173
# Development/Debug Settings
159174
# -----------------------------------------------------------------------------

deploy/cloudrun/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,47 @@ The test script at `scripts/test_deployed_dcr.py` is configurable via environmen
13091309
| `TEST_ACCOUNT_ID` | `test-procurement-account-001` | Procurement account ID |
13101310
| `TEST_REDIRECT_URIS` | `https://gemini.google.com/callback` | Comma-separated redirect URIs |
13111311

1312+
## Audit Logging
1313+
1314+
The agent automatically produces structured audit logs that correlate each user session with Red Hat API requests. When `LOG_FORMAT=json` (the default in Cloud Run), every log record includes:
1315+
1316+
- **`user_id`** — authenticated user (JWT `sub` claim)
1317+
- **`org_id`** — Red Hat organization (JWT `org_id` claim)
1318+
- **`order_id`** — Google Cloud Marketplace order
1319+
- **`request_id`** — UUID4 correlation ID (unique per request)
1320+
1321+
Each agent lifecycle event carries an `event_type` tag (`request_authenticated`, `agent_run_started`, `tool_call_completed`, `mcp_jwt_forwarded`, etc.) and tool calls include a `data_source` field identifying which Red Hat Insights MCP tool retrieved the data.
1322+
1323+
This provides a full data lineage audit trail: every piece of information disclosed by the agent can be traced back to a specific authenticated user prompt and a verified Red Hat Insights data source. These persistent logs are independent of the ephemeral ADK session storage.
1324+
1325+
### Querying Audit Logs
1326+
1327+
Cloud Logging automatically parses JSON log fields. To filter logs from the Lightspeed Agent service specifically, add a `resource.labels.service_name` filter:
1328+
1329+
```bash
1330+
# All Lightspeed Agent logs (filter by Cloud Run service name)
1331+
gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="lightspeed-agent"' \
1332+
--project=$GOOGLE_CLOUD_PROJECT --limit=50
1333+
1334+
# All actions by a specific user (scoped to the agent service)
1335+
gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="lightspeed-agent" AND jsonPayload.user_id="<user-id>"' \
1336+
--project=$GOOGLE_CLOUD_PROJECT --limit=50
1337+
1338+
# All events in a single request (correlation)
1339+
gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="lightspeed-agent" AND jsonPayload.request_id="<request-id>"' \
1340+
--project=$GOOGLE_CLOUD_PROJECT
1341+
1342+
# All MCP data access for an organization
1343+
gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="lightspeed-agent" AND jsonPayload.org_id="<org-id>" AND jsonPayload.message=~"mcp_jwt_forwarded"' \
1344+
--project=$GOOGLE_CLOUD_PROJECT
1345+
1346+
# All tool calls with data source tracking
1347+
gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="lightspeed-agent" AND jsonPayload.message=~"tool_call_completed"' \
1348+
--project=$GOOGLE_CLOUD_PROJECT --limit=20
1349+
```
1350+
1351+
No additional configuration is required — audit logging is automatically active when `LOG_FORMAT=json`.
1352+
13121353
## Monitoring
13131354

13141355
View metrics in Google Cloud Console:

deploy/cloudrun/service.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ spec:
8181
# Updated automatically by deploy.sh after deployment.
8282
- name: MARKETPLACE_HANDLER_URL
8383
value: "https://marketplace-handler.example.com"
84+
# Logging and Audit Trail
85+
# LOG_FORMAT=json enables structured audit logging with user_id,
86+
# org_id, order_id, and request_id in every log record. Cloud
87+
# Logging parses these fields automatically for querying.
8488
- name: LOG_LEVEL
8589
value: "INFO"
8690
- name: LOG_FORMAT

deploy/podman/lightspeed-agent-configmap.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ data:
7373
# Set SERVICE_CONTROL_SERVICE_NAME in secrets for production
7474
SERVICE_CONTROL_ENABLED: "false"
7575

76-
# Logging
76+
# Logging and Audit Trail
77+
# LOG_LEVEL: DEBUG, INFO, WARNING, ERROR
78+
# LOG_FORMAT: "json" (structured with audit fields: user_id, org_id,
79+
# order_id, request_id) or "text" (human-readable, no audit fields)
80+
# AGENT_LOGGING_DETAIL: "basic" (tool names and lifecycle events only)
81+
# or "detailed" (also includes tool arguments and truncated results)
7782
LOG_LEVEL: "DEBUG"
7883
LOG_FORMAT: "json"
7984
AGENT_LOGGING_DETAIL: "detailed"

docs/configuration.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,60 @@ LOG_FORMAT=text # Human-readable for development
249249
AGENT_LOGGING_DETAIL=detailed # Include tool args/results in logs
250250
```
251251

252+
#### Audit Logging
253+
254+
The `LOG_FORMAT` setting controls how log records are formatted:
255+
256+
- **`json`** (default) — Structured JSON output. Every log record automatically includes audit context fields (`user_id`, `org_id`, `order_id`, `request_id`). Recommended for production and Cloud Run, where Cloud Logging parses these fields for querying.
257+
- **`text`** — Human-readable output (`timestamp - logger - level - message`). Audit context fields are **not** included in the log record. The agent execution plugin still embeds `user_id`, `org_id`, `order_id`, and `request_id` in the log message text, but they are not available as structured fields for filtering. Recommended for local development.
258+
259+
When `LOG_FORMAT=json`, every log record automatically includes audit context fields:
260+
261+
| Field | Source | Description |
262+
|-------|--------|-------------|
263+
| `user_id` | JWT `sub` claim | Authenticated user identifier |
264+
| `org_id` | JWT `org_id` claim | Red Hat organization identifier |
265+
| `order_id` | DCR client lookup | Google Cloud Marketplace order |
266+
| `request_id` | Generated UUID4 | Per-request correlation ID |
267+
268+
These fields enable:
269+
- **Request correlation** — all events in a single request share the same `request_id`
270+
- **User audit** — filter by `user_id` to trace all actions by a specific user
271+
- **Organization audit** — filter by `org_id` for organization-level auditing
272+
- **Data lineage**`tool_call_completed` events include `data_source=<mcp_tool>`, and `mcp_jwt_forwarded` events prove data was retrieved using the user's authorized JWT
273+
274+
Each agent lifecycle event is tagged with an `event_type` in the log message:
275+
276+
| Event Type | Description |
277+
|------------|-------------|
278+
| `request_authenticated` | User JWT validated, user_id and org_id extracted |
279+
| `agent_run_started` | ADK agent invocation started |
280+
| `agent_run_completed` | ADK agent invocation finished |
281+
| `llm_call_started` | Gemini LLM call initiated |
282+
| `llm_call_completed` | Gemini LLM call finished (includes token counts) |
283+
| `tool_call_started` | MCP tool call initiated |
284+
| `tool_call_completed` | MCP tool call finished (includes `data_source`) |
285+
| `mcp_jwt_forwarded` | User JWT forwarded to MCP sidecar for Red Hat API auth |
286+
287+
**Example JSON log line:**
288+
289+
```json
290+
{"time": "2025-01-15 10:30:45", "level": "INFO", "logger": "lightspeed_agent.api.a2a.logging_plugin", "message": "Tool call completed (event_type=tool_call_completed, tool=get_advisories, data_source=get_advisories, ...)", "user_id": "user-42", "org_id": "org-7", "order_id": "order-99", "request_id": "abc-123-def-456"}
291+
```
292+
293+
On Cloud Run, these JSON logs are automatically parsed by Cloud Logging and can be queried with:
294+
295+
```bash
296+
# Find all actions by a specific user
297+
gcloud logging read 'jsonPayload.user_id="user-42"' --project=$GOOGLE_CLOUD_PROJECT
298+
299+
# Find all tool calls in a specific request
300+
gcloud logging read 'jsonPayload.request_id="abc-123" AND jsonPayload.message=~"tool_call"' --project=$GOOGLE_CLOUD_PROJECT
301+
302+
# Audit all MCP data access for an organization
303+
gcloud logging read 'jsonPayload.org_id="org-7" AND jsonPayload.message=~"mcp_jwt_forwarded"' --project=$GOOGLE_CLOUD_PROJECT
304+
```
305+
252306
### Development Settings
253307

254308
| Variable | Default | Description |

src/lightspeed_agent/api/a2a/logging_plugin.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
"""Agent execution logging plugin.
1+
"""Agent execution logging plugin with audit context.
22
33
Logs agent lifecycle events (tool calls, LLM invocations, run start/end)
4-
at INFO level for operational visibility. Controlled by the
5-
AGENT_LOGGING_DETAIL setting:
4+
at INFO level for operational visibility. Each log entry includes an
5+
``event_type`` classification and audit context (user_id, org_id,
6+
order_id, request_id) for data lineage tracing.
7+
8+
Controlled by the AGENT_LOGGING_DETAIL setting:
69
- "basic": logs tool names, token counts, and lifecycle events
710
- "detailed": also logs tool arguments and truncated results
811
"""
@@ -18,6 +21,12 @@
1821
from google.adk.tools.base_tool import BaseTool
1922
from google.adk.tools.tool_context import ToolContext
2023

24+
from lightspeed_agent.auth.middleware import (
25+
get_request_id,
26+
get_request_order_id,
27+
get_request_org_id,
28+
get_request_user_id,
29+
)
2130
from lightspeed_agent.config import get_settings
2231

2332
logger = logging.getLogger(__name__)
@@ -34,32 +43,46 @@ def _truncate(value: Any, max_length: int = _MAX_RESULT_LENGTH) -> str:
3443

3544

3645
class AgentLoggingPlugin(BasePlugin):
37-
"""ADK plugin that logs agent execution events at INFO level."""
46+
"""ADK plugin that logs agent execution events with audit context."""
3847

3948
def __init__(self) -> None:
4049
super().__init__(name="agent_logging")
4150

4251
def _is_detailed(self) -> bool:
4352
return get_settings().agent_logging_detail == "detailed"
4453

54+
@staticmethod
55+
def _audit_fields() -> str:
56+
"""Build audit context string for log messages."""
57+
return (
58+
f"user_id={get_request_user_id()}, "
59+
f"org_id={get_request_org_id()}, "
60+
f"order_id={get_request_order_id()}, "
61+
f"request_id={get_request_id()}"
62+
)
63+
4564
# -- run lifecycle --------------------------------------------------------
4665

4766
async def before_run_callback(
4867
self, *, invocation_context: InvocationContext
4968
) -> None:
5069
logger.info(
51-
"Agent run started (invocation_id=%s, agent=%s)",
70+
"Agent run started "
71+
"(event_type=agent_run_started, invocation_id=%s, agent=%s, %s)",
5272
invocation_context.invocation_id,
5373
invocation_context.agent.name,
74+
self._audit_fields(),
5475
)
5576
return None
5677

5778
async def after_run_callback(
5879
self, *, invocation_context: InvocationContext
5980
) -> None:
6081
logger.info(
61-
"Agent run completed (invocation_id=%s)",
82+
"Agent run completed "
83+
"(event_type=agent_run_completed, invocation_id=%s, %s)",
6284
invocation_context.invocation_id,
85+
self._audit_fields(),
6386
)
6487
return None
6588

@@ -68,7 +91,11 @@ async def after_run_callback(
6891
async def before_model_callback(
6992
self, *, callback_context: CallbackContext, llm_request: LlmRequest
7093
) -> Any | None:
71-
logger.info("LLM call started (agent=%s)", callback_context.agent_name)
94+
logger.info(
95+
"LLM call started (event_type=llm_call_started, agent=%s, %s)",
96+
callback_context.agent_name,
97+
self._audit_fields(),
98+
)
7299
return None
73100

74101
async def after_model_callback(
@@ -87,17 +114,24 @@ async def after_model_callback(
87114
model_version = llm_response.model_version if llm_response else None
88115

89116
logger.info(
90-
"LLM call completed (input_tokens=%d, output_tokens=%d, model=%s)",
117+
"LLM call completed "
118+
"(event_type=llm_call_completed, input_tokens=%d, output_tokens=%d, "
119+
"model=%s, %s)",
91120
input_tokens,
92121
output_tokens,
93122
model_version,
123+
self._audit_fields(),
94124
)
95125
return None
96126

97127
async def on_model_error_callback(
98128
self, *, callback_context: CallbackContext, llm_request: LlmRequest, error: Exception
99129
) -> LlmResponse | None:
100-
logger.error("LLM call failed: %s", error)
130+
logger.error(
131+
"LLM call failed (event_type=llm_call_failed, error=%s, %s)",
132+
error,
133+
self._audit_fields(),
134+
)
101135
return None
102136

103137
# -- tool callbacks -------------------------------------------------------
@@ -112,12 +146,19 @@ async def before_tool_callback(
112146
tool_name = getattr(tool, "name", type(tool).__name__)
113147
if self._is_detailed():
114148
logger.info(
115-
"Tool call started (tool=%s, args=%s)",
149+
"Tool call started "
150+
"(event_type=tool_call_started, tool=%s, args=%s, %s)",
116151
tool_name,
117152
_truncate(tool_args),
153+
self._audit_fields(),
118154
)
119155
else:
120-
logger.info("Tool call started (tool=%s)", tool_name)
156+
logger.info(
157+
"Tool call started "
158+
"(event_type=tool_call_started, tool=%s, %s)",
159+
tool_name,
160+
self._audit_fields(),
161+
)
121162
return None
122163

123164
async def after_tool_callback(
@@ -131,12 +172,22 @@ async def after_tool_callback(
131172
tool_name = getattr(tool, "name", type(tool).__name__)
132173
if self._is_detailed():
133174
logger.info(
134-
"Tool call completed (tool=%s, result=%s)",
175+
"Tool call completed "
176+
"(event_type=tool_call_completed, tool=%s, data_source=%s, "
177+
"result=%s, %s)",
178+
tool_name,
135179
tool_name,
136180
_truncate(result),
181+
self._audit_fields(),
137182
)
138183
else:
139-
logger.info("Tool call completed (tool=%s)", tool_name)
184+
logger.info(
185+
"Tool call completed "
186+
"(event_type=tool_call_completed, tool=%s, data_source=%s, %s)",
187+
tool_name,
188+
tool_name,
189+
self._audit_fields(),
190+
)
140191
return None
141192

142193
async def on_tool_error_callback(
@@ -148,5 +199,11 @@ async def on_tool_error_callback(
148199
error: Exception,
149200
) -> dict[str, Any] | None:
150201
tool_name = getattr(tool, "name", type(tool).__name__)
151-
logger.error("Tool call failed (tool=%s): %s", tool_name, error)
202+
logger.error(
203+
"Tool call failed "
204+
"(event_type=tool_call_failed, tool=%s, error=%s, %s)",
205+
tool_name,
206+
error,
207+
self._audit_fields(),
208+
)
152209
return None

src/lightspeed_agent/auth/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
TokenValidationError,
1818
get_token_introspector,
1919
)
20-
from lightspeed_agent.auth.middleware import AuthenticationMiddleware
20+
from lightspeed_agent.auth.middleware import (
21+
AuthenticationMiddleware,
22+
get_request_id,
23+
get_request_org_id,
24+
get_request_user_id,
25+
)
2126
from lightspeed_agent.auth.models import (
2227
AuthenticatedUser,
2328
JWTClaims,
@@ -36,6 +41,9 @@
3641
"get_token_introspector",
3742
# Middleware
3843
"AuthenticationMiddleware",
44+
"get_request_id",
45+
"get_request_org_id",
46+
"get_request_user_id",
3947
# Models
4048
"AuthenticatedUser",
4149
"JWTClaims",

0 commit comments

Comments
 (0)