Skip to content

Commit 1e7c4b2

Browse files
authored
Merge pull request #137 from LAA-Software-Engineering/feat/110-trace-redaction
feat(trace): sanitize, redact, and truncate trace payloads (#110)
2 parents 3a186c4 + d62718c commit 1e7c4b2

12 files changed

Lines changed: 667 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- **Trace payload redaction** (issue #110): trace events are sanitized, key-redacted, and size-capped before SQLite storage. Defaults mask common secret key names; override via `Project.spec.traces.redactKeys`, `maxPayloadBytes`, and `spec.traces.redaction` (`maxDepth`, `maxBytes` for binary previews, `maxStringChars`). HITL edit `argsDiff` is redacted before persistence. Local runs use [trace.NewRecorderForGraph] from project spec.
1112
- **Optional OpenTelemetry trace export** (issue #108): `Project.spec.telemetry` (`enabled`, `serviceName`, `endpoint` with `env:` tokens, `consoleExport`) emits WayFind-aligned `gen_ai.*` spans (`agent.run`, `model.chat`, `tool.exec`, `approval`) alongside SQLite traces. Disabled by default; init failures log a warning and never fail runs. See [`docs/OTEL.md`](docs/OTEL.md) for a Jaeger quick start.
1213
- **`agentctl inspect --web`** — read-only local inspector (default `http://127.0.0.1:8787`) over SQLite state: runs, trace timeline, run steps, applied deployment resources, and checkpoints ([#109](https://github.com/LAA-Software-Engineering/agentic-control-plane/issues/109)).
1314
- **Run checkpointing and resume** (issue #105): SQLite `run_checkpoints` table stores per-run execution snapshots after each completed step. `agentctl run --resume <run-id>` rehydrates interpolation context and continues from the next step without replaying earlier steps. Interrupted runs exit cleanly (status `interrupted`, exit code 0) and cascade with trace retention pruning. Checkpoints are written before step rows are marked succeeded to avoid replay on crash; runs pin `workflow_spec_hash` and `environment_name` for safe resume.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ Notes:
149149
- **`Policy.spec.hitl.interruptOn`** keys are **Tool metadata.name** values; they configure review options (edit rules, switch targets) for calls already gated by **`approvals.requiredFor`** or safety metadata — they do not gate tools on their own.
150150
- **`run`** stores traces in the **same** SQLite file used for plan/apply (default **`.agentic/state.db`** under `--project`). Optional OTLP export (`spec.telemetry`, off by default) is additive only — see [`docs/OTEL.md`](docs/OTEL.md). When enabled you need `serviceName` plus either `consoleExport: true` or an `endpoint` (`https://…` or `env:VAR`, e.g. `env:OTEL_EXPORTER_OTLP_ENDPOINT`). Export that variable before `run` if you use `env:`; if it is missing or the collector is unreachable, `agentctl` logs a warning, skips OTLP, and the workflow still completes (SQLite traces unchanged).
151151
- If **`spec.traces.retentionDays`** is a positive integer, runs older than that many **UTC calendar days** (by `runs.started_at`) are deleted lazily on **`run`** and **`logs`** (child trace rows cascade). Unset or non-positive means no pruning.
152+
- **Trace payload redaction** (issue #110): before SQLite storage, event JSON is sanitized, key-redacted, and size-capped. Defaults mask common secret key names (substring match on map keys). Optional project knobs:
153+
- **`spec.traces.redactKeys`** / **`maxPayloadBytes`** — merged with defaults; also available under **`spec.traces.redaction`** together with **`maxDepth`**, **`maxStringChars`**, and **`maxBytes`** (max bytes for **binary** previews in sanitized values, not the overall JSON cap).
154+
- Stored events may show **`[REDACTED]`**, **`payload_truncated`** / **`preview`**, or depth/binary placeholders in **`logs`** / **`inspect --web`**.
152155
- Use **`logs --run <id>`** after a run if you want a single run’s trace (IDs are printed by **`run`**).
153156

154157
### Global flags (common)

internal/engine/hitl.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,12 @@ func (e *Executor) resolvePendingHitl(
142142
"resolvedUses": uses,
143143
}
144144
if decision.Kind == spec.HitlDecisionEdit {
145-
traceData["argsDiff"] = policy.HitlArgsDiff(pending.With, with)
145+
diff := policy.HitlArgsDiff(pending.With, with)
146+
if e.Trace != nil {
147+
traceData["argsDiff"] = trace.RedactArgsDiff(diff, gate.Review.RedactKeys, e.Trace.Redaction)
148+
} else {
149+
traceData["argsDiff"] = diff
150+
}
146151
}
147152
if decision.Kind == spec.HitlDecisionSwitch {
148153
traceData["switchTarget"] = decision.SwitchTarget

internal/runtime/local/runner.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func (r *Runtime) startWorkflow(ctx context.Context, opts runtime.WorkflowRunOpt
9898
return runID, fmt.Errorf("local: start run: %w", err)
9999
}
100100

101-
rec := trace.NewRecorder(r.Store)
101+
rec := trace.NewRecorderForGraph(r.Store, prep.graph)
102102
if _, err := rec.Append(ctx, runID, "", trace.EventRunStarted, map[string]any{
103103
"workflow": wfName, "environment": opts.EnvironmentName,
104104
}); err != nil {
@@ -170,7 +170,7 @@ func (r *Runtime) resumeWorkflow(ctx context.Context, opts runtime.WorkflowRunOp
170170
return runID, fmt.Errorf("local: mark run running: %w", err)
171171
}
172172

173-
rec := trace.NewRecorder(r.Store)
173+
rec := trace.NewRecorderForGraph(r.Store, prep.graph)
174174
if _, err := rec.Append(ctx, runID, "", trace.EventRunResumed, map[string]any{
175175
"workflow": wfName,
176176
}); err != nil {

internal/spec/kinds.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,20 @@ type ProjectStateConfig struct {
4141
}
4242

4343
type ProjectTracesConfig struct {
44-
Backend string `yaml:"backend,omitempty" json:"backend,omitempty"`
45-
RetentionDays int `yaml:"retentionDays,omitempty" json:"retentionDays,omitempty"`
44+
Backend string `yaml:"backend,omitempty" json:"backend,omitempty"`
45+
RetentionDays int `yaml:"retentionDays,omitempty" json:"retentionDays,omitempty"`
46+
RedactKeys []string `yaml:"redactKeys,omitempty" json:"redactKeys,omitempty"`
47+
MaxPayloadBytes int `yaml:"maxPayloadBytes,omitempty" json:"maxPayloadBytes,omitempty"`
48+
Redaction *ProjectTracesRedactionCfg `yaml:"redaction,omitempty" json:"redaction,omitempty"`
49+
}
50+
51+
// ProjectTracesRedactionCfg tunes sanitize/redact/truncate for trace payloads (issue #110).
52+
type ProjectTracesRedactionCfg struct {
53+
RedactKeys []string `yaml:"redactKeys,omitempty" json:"redactKeys,omitempty"`
54+
MaxDepth int `yaml:"maxDepth,omitempty" json:"maxDepth,omitempty"`
55+
MaxBytes int `yaml:"maxBytes,omitempty" json:"maxBytes,omitempty"`
56+
MaxStringChars int `yaml:"maxStringChars,omitempty" json:"maxStringChars,omitempty"`
57+
MaxPayloadBytes int `yaml:"maxPayloadBytes,omitempty" json:"maxPayloadBytes,omitempty"`
4658
}
4759

4860
// ProjectTelemetryConfig enables optional OpenTelemetry trace export (issue #108).

internal/trace/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@
22
//
33
// [Recorder] checks that a run row exists before appending (clear failure when StartRun was
44
// skipped). Event type strings are defined as Event* constants in events.go (design doc §12.2 I).
5+
//
6+
// Before persistence, event payloads pass through sanitize → redact → truncate ([PrepareEventData],
7+
// issue #110). Use [NewRecorderForGraph] so limits and redactKeys come from Project.spec.traces.
58
package trace

internal/trace/recorder.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ var ErrRunNotFound = errors.New("trace: run not found")
1717

1818
// Recorder appends trace_events rows via [state.RuntimeStore] (design doc §12.2 I, §14.2).
1919
type Recorder struct {
20-
RT state.RuntimeStore
21-
Clock func() time.Time
20+
RT state.RuntimeStore
21+
Clock func() time.Time
22+
Redaction RedactionOptions
2223
}
2324

2425
// NewRecorder returns a recorder backed by rt. rt must not be nil when Append is called.
2526
func NewRecorder(rt state.RuntimeStore) *Recorder {
26-
return &Recorder{RT: rt}
27+
return &Recorder{RT: rt, Redaction: NormalizeRedactionOptions(DefaultRedactionOptions())}
2728
}
2829

2930
func (r *Recorder) now() time.Time {
@@ -57,7 +58,8 @@ func (r *Recorder) Append(ctx context.Context, runID, stepID, typ string, data m
5758

5859
dataJSON := "{}"
5960
if len(data) > 0 {
60-
b, err := json.Marshal(data)
61+
prepared := PrepareEventData(data, nil, r.Redaction)
62+
b, err := json.Marshal(prepared)
6163
if err != nil {
6264
return 0, fmt.Errorf("trace: marshal event data: %w", err)
6365
}

internal/trace/recorder_graph.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package trace
2+
3+
import (
4+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
5+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
6+
)
7+
8+
// NewRecorderForGraph returns a recorder with redaction options from project spec.
9+
func NewRecorderForGraph(rt state.RuntimeStore, g *spec.ProjectGraph) *Recorder {
10+
return &Recorder{
11+
RT: rt,
12+
Redaction: NormalizeRedactionOptions(RedactionFromGraph(g)),
13+
}
14+
}

0 commit comments

Comments
 (0)