Skip to content

Commit b70b92d

Browse files
feat(go): add ContentCaptureMode for SDK-level content stripping
Callers now have a way to prevent text from leaving the process via the export pipeline or OTel attributes. MetadataOnly mode strips all text content at the SDK level while preserving message structure, tool names, usage, and timing so server-side analytics still work. The existing IncludeContent bool on ToolExecutionStart only controlled span attributes for individual tools, this replaces it with a mode that applies to the entire generation and propagates to child tool executions via context.
1 parent a6d244e commit b70b92d

File tree

11 files changed

+977
-24
lines changed

11 files changed

+977
-24
lines changed

go/sigil/client.go

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ type Config struct {
2626
GenerationExport GenerationExportConfig
2727
API APIConfig
2828
EmbeddingCapture EmbeddingCaptureConfig
29+
// ContentCapture controls the default content capture mode for all
30+
// generations and tool executions. Per-recording overrides take precedence.
31+
ContentCapture ContentCaptureMode
32+
// ContentCaptureResolver, when set, is called before each generation,
33+
// tool execution, and rating submission to dynamically resolve the content
34+
// capture mode. It receives the request context and the recording's
35+
// metadata (nil when the recording type has no metadata, e.g. tool
36+
// executions).
37+
//
38+
// Resolution precedence (highest → lowest):
39+
// 1. Per-recording ContentCapture field (explicit override)
40+
// 2. ContentCaptureResolver return value
41+
// 3. Config.ContentCapture (static default)
42+
//
43+
// Returning ContentCaptureModeDefault defers to Config.ContentCapture.
44+
// Panics are recovered and treated as ContentCaptureModeMetadataOnly
45+
// (fail-closed).
46+
ContentCaptureResolver func(ctx context.Context, metadata map[string]any) ContentCaptureMode
2947
// Tracer is optional and mainly used for tests. If nil, the client uses the global OpenTelemetry tracer.
3048
Tracer trace.Tracer
3149
// Meter is optional and mainly used for tests. If nil, the client uses the global OpenTelemetry meter.
@@ -226,11 +244,12 @@ type Client struct {
226244
//
227245
// All methods are safe to call on a nil or no-op recorder.
228246
type GenerationRecorder struct {
229-
client *Client
230-
ctx context.Context
231-
span trace.Span
232-
seed GenerationStart
233-
startedAt time.Time
247+
client *Client
248+
ctx context.Context
249+
span trace.Span
250+
seed GenerationStart
251+
startedAt time.Time
252+
contentCaptureMode ContentCaptureMode
234253

235254
mu sync.Mutex
236255
ended bool
@@ -455,12 +474,19 @@ func (c *Client) startGeneration(ctx context.Context, start GenerationStart, def
455474
ThinkingEnabled: cloneBoolPtr(seed.ThinkingEnabled),
456475
})...)
457476

477+
// Resolve content capture mode: per-recording > resolver > client default.
478+
resolverMode := callContentCaptureResolver(c.config.ContentCaptureResolver, ctx, seed.Metadata)
479+
clientMode := resolveClientContentCaptureMode(resolveContentCaptureMode(resolverMode, c.config.ContentCapture))
480+
ccMode := resolveContentCaptureMode(seed.ContentCapture, clientMode)
481+
callCtx = withContentCaptureMode(callCtx, ccMode)
482+
458483
return callCtx, &GenerationRecorder{
459-
client: c,
460-
ctx: callCtx,
461-
span: span,
462-
seed: seed,
463-
startedAt: startedAt,
484+
client: c,
485+
ctx: callCtx,
486+
span: span,
487+
seed: seed,
488+
startedAt: startedAt,
489+
contentCaptureMode: ccMode,
464490
}
465491
}
466492

@@ -575,13 +601,16 @@ func (c *Client) StartToolExecution(ctx context.Context, start ToolExecutionStar
575601
attrs := toolSpanAttributes(seed)
576602
span.SetAttributes(attrs...)
577603

604+
ctxMode, ctxSet := contentCaptureModeFromContext(ctx)
605+
includeContent := shouldIncludeToolContent(seed.ContentCapture, ctxMode, ctxSet, c.config.ContentCapture, seed.IncludeContent)
606+
578607
return callCtx, &ToolExecutionRecorder{
579608
client: c,
580609
ctx: callCtx,
581610
span: span,
582611
seed: seed,
583612
startedAt: startedAt,
584-
includeContent: seed.IncludeContent,
613+
includeContent: includeContent,
585614
}
586615
}
587616

@@ -661,14 +690,19 @@ func (r *GenerationRecorder) End() {
661690
normalized := r.normalizeGeneration(generation, completedAt, callErr)
662691
applyTraceContextFromSpan(r.span, &normalized)
663692

693+
stampContentCaptureMetadata(&normalized, r.contentCaptureMode)
694+
if r.contentCaptureMode == ContentCaptureModeMetadataOnly {
695+
stripContent(&normalized, classifyErrorCategory(callErr, false))
696+
}
697+
664698
r.span.SetName(generationSpanName(normalized))
665699
r.span.SetAttributes(generationSpanAttributes(normalized)...)
666700

667701
r.mu.Lock()
668702
r.lastGeneration = cloneGeneration(normalized)
669703
r.mu.Unlock()
670704

671-
enqueueErr := r.client.persistGeneration(r.ctx, normalized)
705+
enqueueErr := r.client.persistGeneration(normalized)
672706

673707
// Record errors on span.
674708
if callErr != nil {
@@ -1078,11 +1112,10 @@ func combineAllErrors(errs ...error) error {
10781112
return errors.Join(filtered...)
10791113
}
10801114

1081-
func (c *Client) persistGeneration(_ context.Context, generation Generation) error {
1082-
if err := ValidateGeneration(generation); err != nil {
1115+
func (c *Client) persistGeneration(generation Generation) error {
1116+
if err := validateGeneration(generation); err != nil {
10831117
return fmt.Errorf("%w: %v", errGenerationValidation, err)
10841118
}
1085-
10861119
if err := c.enqueueGeneration(generation); err != nil {
10871120
return fmt.Errorf("%w: %w", errGenerationEnqueue, err)
10881121
}

go/sigil/content_capture.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package sigil
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
// ContentCaptureMode controls what content is included in exported generation
10+
// payloads and OTel span attributes.
11+
type ContentCaptureMode int
12+
13+
const (
14+
// ContentCaptureModeDefault uses the parent or client-level default.
15+
// On Config this resolves to Full for backward compatibility.
16+
// On GenerationStart this inherits from Config.
17+
// On ToolExecutionStart this inherits from the parent generation context,
18+
// falling back to Config.
19+
ContentCaptureModeDefault ContentCaptureMode = iota
20+
// ContentCaptureModeFull exports all content.
21+
ContentCaptureModeFull
22+
// ContentCaptureModeMetadataOnly preserves message structure, tool names,
23+
// usage, and timing but strips text, tool arguments, tool results,
24+
// thinking, system prompts, and raw artifacts.
25+
//
26+
// Note: user-provided Metadata and Tags are NOT stripped — callers are
27+
// responsible for ensuring these maps do not contain sensitive content
28+
// when using MetadataOnly mode.
29+
ContentCaptureModeMetadataOnly
30+
)
31+
32+
const (
33+
metadataKeyContentCaptureMode = "sigil.sdk.content_capture_mode"
34+
contentCaptureModeValueFull = "full"
35+
contentCaptureModeValueMetaOnly = "metadata_only"
36+
)
37+
38+
// resolveContentCaptureMode returns the effective mode from an override and a
39+
// fallback. Default is transparent — it falls through to the fallback.
40+
func resolveContentCaptureMode(override, fallback ContentCaptureMode) ContentCaptureMode {
41+
if override != ContentCaptureModeDefault {
42+
return override
43+
}
44+
return fallback
45+
}
46+
47+
// callContentCaptureResolver invokes the resolver callback safely, recovering
48+
// from panics. Returns ContentCaptureModeDefault when the resolver is nil.
49+
// Panics are treated as ContentCaptureModeMetadataOnly (fail-closed).
50+
func callContentCaptureResolver(resolver func(ctx context.Context, metadata map[string]any) ContentCaptureMode, ctx context.Context, metadata map[string]any) (mode ContentCaptureMode) {
51+
if resolver == nil {
52+
return ContentCaptureModeDefault
53+
}
54+
defer func() {
55+
if r := recover(); r != nil {
56+
mode = ContentCaptureModeMetadataOnly
57+
}
58+
}()
59+
return resolver(ctx, metadata)
60+
}
61+
62+
// resolveClientContentCaptureMode resolves the effective mode for the client.
63+
// Default at the client level means Full (backward compatibility).
64+
func resolveClientContentCaptureMode(mode ContentCaptureMode) ContentCaptureMode {
65+
if mode == ContentCaptureModeDefault {
66+
return ContentCaptureModeFull
67+
}
68+
return mode
69+
}
70+
71+
// stampContentCaptureMetadata sets the content capture mode marker on the generation.
72+
func stampContentCaptureMetadata(g *Generation, mode ContentCaptureMode) {
73+
if g.Metadata == nil {
74+
g.Metadata = map[string]any{}
75+
}
76+
g.Metadata[metadataKeyContentCaptureMode] = mode.String()
77+
}
78+
79+
// isContentStripped reports whether the generation has been through MetadataOnly
80+
// stripping, based on the stamped metadata marker.
81+
func isContentStripped(g Generation) bool {
82+
if g.Metadata == nil {
83+
return false
84+
}
85+
v, _ := g.Metadata[metadataKeyContentCaptureMode].(string)
86+
return v == contentCaptureModeValueMetaOnly
87+
}
88+
89+
// stripContent removes sensitive content from a generation while preserving
90+
// message structure (roles, part kinds), tool names/IDs, usage, timing, and
91+
// all other metadata fields. errorCategory is the classified error category
92+
// (e.g. "rate_limit", "timeout") used to replace the raw CallError text.
93+
func stripContent(g *Generation, errorCategory string) {
94+
g.SystemPrompt = ""
95+
g.Artifacts = nil
96+
97+
if g.CallError != "" {
98+
if errorCategory != "" {
99+
g.CallError = errorCategory
100+
} else {
101+
g.CallError = "sdk_error"
102+
}
103+
}
104+
delete(g.Metadata, "call_error")
105+
106+
for i := range g.Input {
107+
stripMessageContent(&g.Input[i])
108+
}
109+
for i := range g.Output {
110+
stripMessageContent(&g.Output[i])
111+
}
112+
for i := range g.Tools {
113+
g.Tools[i].Description = ""
114+
g.Tools[i].InputSchema = nil
115+
}
116+
}
117+
118+
func stripMessageContent(m *Message) {
119+
for i := range m.Parts {
120+
m.Parts[i].Text = ""
121+
m.Parts[i].Thinking = ""
122+
if m.Parts[i].ToolCall != nil {
123+
m.Parts[i].ToolCall.InputJSON = nil
124+
}
125+
if m.Parts[i].ToolResult != nil {
126+
m.Parts[i].ToolResult.Content = ""
127+
m.Parts[i].ToolResult.ContentJSON = nil
128+
}
129+
}
130+
}
131+
132+
// shouldIncludeToolContent determines whether tool execution content (arguments,
133+
// results) should be included in span attributes. It resolves the effective mode
134+
// from the explicit override, context, client default, and legacy IncludeContent.
135+
func shouldIncludeToolContent(toolMode, ctxMode ContentCaptureMode, ctxSet bool, clientDefault ContentCaptureMode, legacyInclude bool) bool {
136+
resolved := resolveClientContentCaptureMode(clientDefault)
137+
if ctxSet {
138+
resolved = ctxMode
139+
}
140+
if toolMode != ContentCaptureModeDefault {
141+
resolved = toolMode
142+
}
143+
if resolved == ContentCaptureModeMetadataOnly {
144+
return false
145+
}
146+
// In Full mode, fall back to legacy IncludeContent behavior.
147+
return legacyInclude
148+
}
149+
150+
// String returns the string representation of a ContentCaptureMode.
151+
func (m ContentCaptureMode) String() string {
152+
switch m {
153+
case ContentCaptureModeMetadataOnly:
154+
return contentCaptureModeValueMetaOnly
155+
case ContentCaptureModeFull:
156+
return contentCaptureModeValueFull
157+
default:
158+
return "default"
159+
}
160+
}
161+
162+
// MarshalText implements encoding.TextMarshaler for ContentCaptureMode.
163+
func (m ContentCaptureMode) MarshalText() ([]byte, error) {
164+
return []byte(m.String()), nil
165+
}
166+
167+
// UnmarshalText implements encoding.TextUnmarshaler for ContentCaptureMode.
168+
func (m *ContentCaptureMode) UnmarshalText(text []byte) error {
169+
switch strings.ToLower(string(text)) {
170+
case contentCaptureModeValueFull:
171+
*m = ContentCaptureModeFull
172+
case contentCaptureModeValueMetaOnly:
173+
*m = ContentCaptureModeMetadataOnly
174+
case "default", "":
175+
*m = ContentCaptureModeDefault
176+
default:
177+
return fmt.Errorf("unknown content capture mode: %q", string(text))
178+
}
179+
return nil
180+
}

0 commit comments

Comments
 (0)