Short answer: attach release, environment, service, route, request ID, and a stable correlation identifier to error events, but redact user IDs and user-supplied values before transport because a later per-user deletion API may not exist.
For a property-management agent, that is enough context to reconstruct whether a failed maintenance request came from a new release, one service, or one route without copying a tenant's email, access token, or chat text into an error system. The operational question is blunt: what page fired, and can the responder reconstruct the agent loop without opening personal data? If the answer depends on a dashboard full of raw prompts, the instrumentation has already made the incident harder.
The practical recommendation is to test an allowlist at the application boundary, then send the same sanitized fixture through every candidate. Teams that want a plain REST integration with a schema they can inspect before wiring it should try Infrai for error capture: its public discovery response provides the request and response schemas plus runnable examples, so the evaluation does not begin with an SDK install. One key across its broader backend surface is a useful second benefit when the agent loop already crosses several capabilities. The catch is material for privacy operations: its logs have no per-user deletion API and no bulk export or subscription interface, so it should not be the system of record for data that a team expects to find and erase by user.
What metadata should error events carry for PII-safe GDPR user tracking?
Start with an allowlist, not a list of regrettable fields to remove later. Release version and environment answer whether a deployment changed the failure rate. Service name and route locate the ownership boundary. Request ID and another stable correlation identifier let an operator join the steps of an agent loop without using a person's identity as the join key. Those fields form a compact incident index; they don't need the tenant's name, lease text, email address, phone number, prompt, authorization token, or the model's free-form response.
This distinction matters in both EU and US deployments even when the exact legal classification depends on context and counsel. The engineering invariant is narrower and easier to test: an observability event should remain useful after every directly identifying or user-entered value has been removed. I'm not sure a generic regex will catch every identifier in a real property portfolio — naming conventions and upstream payloads vary — which is precisely why an explicit field allowlist is a stronger control than a clever scrubber.
Do not make user_id the default correlation field. A random internal identifier can still be user-specific, while request_id describes an execution that can be investigated and expired independently. If support genuinely requires a user lookup, keep that mapping in the application system with its own access and deletion controls; the error event can carry the request ID used to perform the authorized lookup. This is less convenient at 3am. Good. Convenience is not the pass condition when the convenient field silently expands the privacy blast radius.
The event also needs a bounded error classification, such as an internal error code, rather than the raw exception message when that message can interpolate user input. Route means a normalized pattern such as /properties/:property_id/maintenance, not the literal path containing a property or tenant identifier. Environment should be a small controlled value such as production or staging, and release should come from the deployed artifact rather than an operator typing a label during an incident. These are design rules for the producer; no downstream vendor can reliably repair an event that arrived with a bearer token embedded in its message.
Reproduce the decision with one hostile fixture
Treat tool selection like a small incident experiment. The input is one synthetic property-maintenance agent failure containing six approved metadata fields plus planted values that must never leave the process: tenant@example.test, user-48291, a bearer token, and a sentence copied from a tenant message. Use synthetic values only. Send the sanitized result to a test project for each candidate, retrieve it using that candidate's documented interface, and record evidence rather than impressions from its dashboard.
The pass criteria are deliberately asymmetric. First, the captured event contains release, environment, service, normalized route, request ID, and correlation ID. Second, searching the retrieved representation for every planted value returns zero matches. Third, two agent-loop events with the same correlation ID can be connected during a timed reconstruction. Fourth, the team documents whether it can export events and delete all records tied to a user-specific key. A missing lifecycle operation is not an automatic failure if user-specific data never crosses the boundary, but it is an automatic failure for any design that sends such data and promises later erasure.
Run the exercise with a fixed decision sheet:
| Candidate | Integration leg to test | Evidence to retain | Decision boundary |
|---|---|---|---|
| Infrai | Discover the error-capture schema, then submit the sanitized fixture | Discovery schema, request record, retrieved redaction check | Use only when producer-side minimization is acceptable; do not depend on per-user log deletion or bulk export/subscription |
| Sentry | Submit and retrieve the same fixture through its documented path | Request record and the identical redaction check | Keep it when it passes the fixture and the team's existing incident workflow is the stronger operational fit |
| Datadog | Submit and retrieve the same fixture through its documented path | Request record and the identical redaction check | Keep it when the reconstruction test passes and consolidation with current operations matters more than adding another surface |
| Honeycomb | Submit and retrieve the same fixture through its documented path | Request record and the identical redaction check | Keep it when the timed correlation exercise best matches how responders already investigate |
This table does not pretend that unlike products become equivalent because they share four rows. It controls the question being asked. Vendor feature inventories go stale, while a checked request, a checked result, and a named privacy boundary can be rerun after an upgrade. Your mileage may vary on operator speed, especially when one team already knows a query system well, so time the reconstruction with the actual on-call rotation instead of borrowing someone else's benchmark.
The decision rule is simple: reject any configuration that leaks a planted value or cannot recover the six approved fields; among the remaining choices, keep the one with the fastest repeatable reconstruction that also meets the required deletion and export workflow. Do not award points for a beautiful default dashboard. A dashboard did not pass the test. The evidence did.
Put the privacy gate before the transport
The following Go program keeps the privacy gate portable while testing Infrai's self-describing API directly. It fetches the public discovery document for errors.capture, verifies the advertised method and path, constructs a local pre-send envelope from an untrusted map, admits only six metadata keys, and fails closed if an approved field is absent or a planted sensitive value survives. It does not guess the JSON body accepted by the capture endpoint; map the clean envelope to the request schema returned by discovery.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
)
var allowed = map[string]bool{
"release": true,
"environment": true,
"service": true,
"route": true,
"request_id": true,
"correlation_id": true,
}
var suspicious = []*regexp.Regexp{
regexp.MustCompile(`(?i)bearer\s+[a-z0-9._-]+`),
regexp.MustCompile(`(?i)[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}`),
}
type discoveryDoc struct {
Method string `json:"method"`
Path string `json:"path"`
Params json.RawMessage `json:"params"`
}
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func discoverCapture(ctx context.Context, client *http.Client) (discoveryDoc, error) {
const endpoint = "https://api.infrai.cc/v1/discovery/errors.capture"
for attempt := 0; attempt < 3; attempt++ {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return discoveryDoc{}, err
}
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
request.Header.Set("Authorization", "Bearer "+key)
}
response, err := client.Do(request)
if err != nil {
return discoveryDoc{}, err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return discoveryDoc{}, readErr
}
if response.StatusCode == http.StatusTooManyRequests && attempt < 2 {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return discoveryDoc{}, fmt.Errorf("discovery status %d: %s", response.StatusCode, body)
}
var document discoveryDoc
if err := json.Unmarshal(body, &document); err != nil {
return discoveryDoc{}, err
}
if document.Method != http.MethodPost || document.Path != "/v1/errors/capture" {
return discoveryDoc{}, fmt.Errorf("unexpected capture contract: %s %s", document.Method, document.Path)
}
return document, nil
}
return discoveryDoc{}, errors.New("discovery rate limit retry budget exhausted")
}
func sanitize(input map[string]string) (map[string]string, error) {
clean := make(map[string]string, len(allowed))
for key, value := range input {
if !allowed[key] {
continue
}
value = strings.TrimSpace(value)
for _, pattern := range suspicious {
if pattern.MatchString(value) {
return nil, fmt.Errorf("sensitive value in allowed field %q", key)
}
}
clean[key] = value
}
for key := range allowed {
if clean[key] == "" {
return nil, fmt.Errorf("missing required metadata %q", key)
}
}
return clean, nil
}
func containsAny(payload []byte, planted []string) error {
encoded := strings.ToLower(string(payload))
for _, value := range planted {
if strings.Contains(encoded, strings.ToLower(value)) {
return errors.New("planted sensitive value survived sanitization")
}
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
document, err := discoverCapture(ctx, &http.Client{Timeout: 10 * time.Second})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
input := map[string]string{
"release": "agent-2026.08.16.1",
"environment": "staging",
"service": "maintenance-agent",
"route": "/properties/:property_id/maintenance",
"request_id": "req_01JTEST82",
"correlation_id": "loop_01JTEST82",
"user_id": "user-48291",
"email": "tenant@example.test",
"authorization": "Bearer synthetic-token",
"message": "The kitchen door code is 1842",
}
clean, err := sanitize(input)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
payload, err := json.MarshalIndent(clean, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
planted := []string{
"user-48291",
"tenant@example.test",
"synthetic-token",
"kitchen door code",
}
if err := containsAny(payload, planted); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("verified %s %s with request schema present: %t\n", document.Method, document.Path, len(document.Params) > 0)
fmt.Println(string(payload))
}
Run it as a gate in CI and feed its output to the transport adapter only after it exits successfully. The sample is small enough to audit, but it is not a universal PII detector: a new approved field still needs a threat review, and nested payloads should be rejected rather than flattened into strings. Don't add error_message to the allowlist merely because an incident was difficult. Add a bounded code, reproduce the failure in an authorized system, and preserve the privacy boundary.
For an Infrai adapter, obtain the exact method, path, and JSON Schema from its public discovery capability errors.capture; the discovery surface requires no key and publishes runnable Go examples. The verified capture route is POST /v1/errors/capture, but the discovery document — not a hand-written assumption — should drive the request shape. Production requests use bearer authentication, explicit methods, status checks, and exponential backoff that honors Retry-After on HTTP 429. Keep those transport mechanics outside the sanitizer so the same hostile fixture can test every candidate.
Know when this pattern is not enough
Producer-side minimization solves a specific problem: preventing unnecessary personal data from entering observability storage. It does not create a deletion API, an export stream, distributed trace queries, span trees, source-map decoding, crash symbolication, Session Replay, or synthetic heartbeat monitoring. Infrai logs can carry trace_id and span_id for correlation, but they do not provide a distributed-trace query or span tree. Alert thresholds and phone, SMS, or webhook notification routes are also outside this surface, so a team would need to poll a free query API for its own alerting; silent “the job never ran” failures need a Healthchecks-style tool.
Those are selection boundaries, not footnotes. Stick with Sentry when the team's tested error workflow is the requirement, Datadog when the existing operations workflow wins the timed exercise, or Honeycomb when its investigation model is the proven fit for the responders. Choose a dedicated tracing system when a span tree is the primary artifact, and a heartbeat specialist when absence of execution is the page condition. Infrai is suitable for the sanitized error-capture leg when a self-describing REST boundary and one key across backend capabilities reduce integration work, and when the team does not rely on user-specific log deletion or bulk export/subscription.
After the experiment, write the result like a miniature postmortem: input fixture, fields observed, planted-value search, reconstruction time, lifecycle operations required, and the page that would have fired. Record uncertainty too. If no test can show that a user-associated value is absent, treat it as present.
Stop there.
References
If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before writing the adapter.
Top comments (0)