Skip to content

feat(a2asrv): add AuditLogger for structured A2A call auditing - #379

Open
kuangmi-bit wants to merge 6 commits into
a2aproject:mainfrom
kuangmi-bit:feat/audit-logger
Open

feat(a2asrv): add AuditLogger for structured A2A call auditing#379
kuangmi-bit wants to merge 6 commits into
a2aproject:mainfrom
kuangmi-bit:feat/audit-logger

Conversation

@kuangmi-bit

Copy link
Copy Markdown
Contributor

Summary

Add a2asrv/auditlogger — a server-side CallInterceptor that records structured audit events for every A2A protocol method invocation.

Motivation

The existing log/ package provides unstructured logging. Enterprise deployments need structured, machine-readable audit trails for compliance, monitoring, and debugging. The audit event schema aligns with IBM SMF Type 110 (CICS transaction audit): who called what method, when, with what result, and at what duration.

Design

  • AuditLogger: a CallInterceptor. Before() records start time; After() builds and writes the event.
  • AuditWriter: pluggable interface. Ships with:
    • InMemoryWriter: for tests and dev
    • JSONLWriter: production-ready, concurrent-safe JSON lines file
  • Events distinguish success, error, and cancelled results
  • CallContext.User.Name used as agent identifier

Example

w, _ := auditlogger.NewJSONLWriter("/var/log/a2a/audit.jsonl")
al := auditlogger.NewAuditLogger(w)
handler := a2asrv.NewHandler(executor, a2asrv.WithCallInterceptors(al))

Testing

7 tests pass: success/error/cancelled result types, multiple events, no-user edge case, JSONL file writer, concurrent writes (200 events across 10 goroutines).

sg-architect added 2 commits July 19, 2026 06:03
Add a new a2asrv/costlimiter package that provides cost-based budget
enforcement as a CallInterceptor. This complements the existing
ConcurrencyConfig (which limits in-flight executions) with cost-aware
budgeting — useful for LLM token budgets, compute-time quotas, or
monetary credit limits.

Key components:
- CostLimiter: a server-side CallInterceptor that estimates request cost
  via a pluggable CostFunc and reserves budget via a CostStore
- CostStore interface: Available/Reserve/Release with atomic semantics
- InMemoryCostStore: thread-safe in-memory implementation
- ScopeFunc: per-tenant/per-user budget scoping via CallContext
- Cancelled requests release reserved budget back to the pool

All 8 tests pass. The package has no external dependencies beyond a2asrv.
Add a new a2asrv/auditlogger package that records structured audit events
for every A2A protocol method invocation as a CallInterceptor.

The audit event schema aligns with IBM SMF Type 110 (CICS transaction audit):
who called what, when, with what result, and duration.

Key components:
- AuditLogger: server-side CallInterceptor, records Before→After timing
- AuditWriter interface: pluggable backend for audit event persistence
- InMemoryWriter: thread-safe in-memory store for testing
- JSONLWriter: production-ready JSON lines file appender
- Cancelled calls are distinguished from errors
- CallContext.User.Name used as agent identifier
- Extensions tracked per call

7 tests pass.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces two new packages: auditlogger, which provides a server-side call interceptor for recording structured audit events to memory or JSONL files, and costlimiter, which implements cost-based budget enforcement for request handling. The review feedback highlights several critical improvements: resolving a memory leak in InMemoryCostStore by not storing unlimited scopes, using errors.Is to properly detect wrapped cancellation errors, optimizing JSONLWriter performance by moving JSON serialization outside of the critical section, and removing a redundant, non-atomic budget availability check before reservation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +192 to +196
v, ok := s.budget[scope]
if !ok {
s.budget[scope] = -cost
return true, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When a scope is not present in the budget map, it is considered unlimited. However, storing s.budget[scope] = -cost in the map for every new unlimited scope will cause the map to grow indefinitely over time (e.g., with unique user or agent IDs), leading to a memory leak. Since unlimited scopes do not need to be tracked, simply return true, nil without modifying the map.

	v, ok := s.budget[scope]
	if !ok {
		return true, nil
	}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Reserve() now returns true, nil for unlimited scopes without modifying the map — no more memory leak.

Comment thread a2asrv/auditlogger/auditlogger.go Outdated

if resp != nil {
if resp.Err != nil {
if resp.Err == context.Canceled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use errors.Is instead of direct comparison (==) to check for context.Canceled. In Go, errors can be wrapped (e.g., via fmt.Errorf), and direct comparison will fail to detect wrapped cancellation errors. Note that you will need to import the errors package.

Suggested change
if resp.Err == context.Canceled {
if errors.Is(resp.Err, context.Canceled) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Changed to errors.Is(resp.Err, context.Canceled) for proper wrapped-error detection.

Comment on lines +185 to +195
func (w *JSONLWriter) Write(_ context.Context, ev *AuditEvent) error {
w.mu.Lock()
defer w.mu.Unlock()
b, err := json.Marshal(ev)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.file.Write(b)
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing json.Marshal inside the critical section (while holding w.mu) can become a major performance bottleneck under high concurrent load, as JSON serialization is relatively CPU-intensive. Move json.Marshal outside of the lock so that only the actual file write operation is serialized.

Suggested change
func (w *JSONLWriter) Write(_ context.Context, ev *AuditEvent) error {
w.mu.Lock()
defer w.mu.Unlock()
b, err := json.Marshal(ev)
if err != nil {
return err
}
b = append(b, '\n')
_, err = w.file.Write(b)
return err
}
func (w *JSONLWriter) Write(_ context.Context, ev *AuditEvent) error {
b, err := json.Marshal(ev)
if err != nil {
return err
}
b = append(b, '\n')
w.mu.Lock()
defer w.mu.Unlock()
_, err = w.file.Write(b)
return err
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Moved json.Marshal outside the critical section — only file.Write holds the lock now.

Comment thread a2asrv/costlimiter/costlimiter.go Outdated
Comment on lines +113 to +128
available, err := cl.store.Available(ctx, scope)
if err != nil {
return ctx, nil, fmt.Errorf("costlimiter: checking budget: %w", err)
}
if available >= 0 && available < cost {
return ctx, nil, fmt.Errorf("%w: scope %q: need %d, available %d",
ErrBudgetExceeded, scope, cost, available)
}

ok, err := cl.store.Reserve(ctx, scope, cost)
if err != nil {
return ctx, nil, fmt.Errorf("costlimiter: reserving budget: %w", err)
}
if !ok {
return ctx, nil, fmt.Errorf("%w: scope %q: budget exhausted", ErrBudgetExceeded, scope)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling cl.store.Available before cl.store.Reserve is redundant and introduces a non-atomic check-then-act window. Since Reserve is already responsible for atomically checking and deducting the budget, calling Available first results in an extra round-trip to the store (which could be a database or Redis in production) and doubles lock contention on the store's mutex. You can safely remove the Available check and call Reserve directly.

	ok, err := cl.store.Reserve(ctx, scope, cost)
	if err != nil {
		return ctx, nil, fmt.Errorf("costlimiter: reserving budget: %w", err)
	}
	if !ok {
		return ctx, nil, fmt.Errorf("%w: scope %q: budget exhausted", ErrBudgetExceeded, scope)
	}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed the redundant Available() check. Reserve() is already atomic — the check-then-act window is eliminated.

邝谧 and others added 4 commits July 20, 2026 08:16
costlimiter.go:
- Import limiter package for AttachScope/ScopeFrom
- Remove default scopeFn initialization (leave nil), add resolveScope()
- Before(): use resolveScope(), remove redundant Available() before Reserve()
- After(): use context.WithoutCancel() for Release on cancelled contexts
- InMemoryCostStore.Reserve(): don't store unlimited scopes in budget map

costlimiter_test.go:
- Import limiter package
- Add TestCostLimiter_FallbackToLimiterScope
- Add TestInMemoryCostStore_UnlimitedScopeNotStored

auditlogger.go:
- Add errors import
- Use errors.Is(resp.Err, context.Canceled) instead of == comparison
- Move json.Marshal outside mutex lock in JSONLWriter.Write()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants