Chris Tam · Last updated 21 October 2026
Users paste anything into a chat box: their email, a phone number, a card they want refunded. Agent analytics reads conversation text to judge quality, so the goal is to send that text clean, not to stop sending it.
In this tutorial, I will test the SDK's built-in redaction, extend it to your own IDs and to names, pick a privacy mode, and confirm what reached Amplitude. It builds on an app that already sends agent events.
A app/privacy.py module that sets content_mode, keeps redact_pii on, and adds your own patterns and a named-entity scrubber, plus a pytest that runs a session with fake PII in review mode and fails if a raw email, phone number, card number, SSN, IP address, customer ID, or name would leave the process. Node.js equivalents sit beside each Python step. By the end you'll see [email], [phone], and [REDACTED] in place of real values in the agent analytics session viewer.
Prerequisites
- A Python or Node.js backend that already sends agent events with the Amplitude AI SDK, for example from How to add agent analytics to a Next.js app or the FastAPI, Django, and Node.js agent analytics tutorials. The code below extends the
app/agent_analytics.py(Python) andlib/amplitude-ai.js(Node.js) modules from those pages. - An Amplitude project with agent analytics enabled, its project API key (Settings > Projects > your project > General), and the View Agent Analytics Objects permission.
- pytest for the Python test, or Node.js 20+ for the built-in
node:testrunner.
No Amplitude account yet? A free account is enough for this tutorial; every plan includes a free allotment of agent sessions. Sign up for free to get your project API key.
1. Run a version that redacts by default
In full mode, the default, the SDK scans every content channel it sends and replaces matches with fixed placeholders before the event leaves your process. Here is what it catches, from the privacy modes reference:
- Email address. Formats matched:
name@domain.tld, including accented and non-Latin local parts Replacement:[email] - Phone number. Formats matched: US 10-digit numbers with optional parentheses, dots, dashes, or spaces, and E.164 numbers (
+and 7 to 15 digits) Replacement:[phone] - Credit card number. Formats matched: 16 digits in groups of four, with optional dashes or spaces Replacement:
[credit_card] - Social Security number. Formats matched:
123-45-6789or123 45 6789Replacement:[ssn] - IP address. Formats matched: IPv4 and IPv6, including bracketed URL forms such as
http://[::1]:8080Replacement:[ip_address] - Base64 image. Formats matched: A whole value that's a
data:base64 URL, or raw base64 long enough to be an image Replacement:[base64 image redacted]
The content channels it scans are message text, system prompts, tool inputs and outputs, tool definitions, reasoning content, and score comments.
In Python, redaction is only on by default from amplitude-ai 1.17.0. Earlier versions default redact_pii to False. Use 1.20.1 or later for this tutorial's privacy and review-mode features:
uv add "amplitude-ai>=1.20.1"
uv run amplitude-ai statusuv run amplitude-ai status prints the installed SDK version. In Node.js, redactPii defaults to true. This tutorial uses @amplitude/ai 0.24.1 or later: pnpm add "@amplitude/ai@>=0.24.1" @amplitude/analytics-node.
Why. Upgrading an older Python install to 1.17.0 starts scrubbing content that was sent raw before. If a dashboard or evaluator depends on raw text, you'll see placeholders appear from the deploy onward. Set redact_pii=True explicitly anyway, as the next step does, so a downgrade can't turn it off.
2. Prove the default redaction in a test
Put the privacy settings in a module with no side effects, so a test can build a client with exactly the config production uses. Your existing app/agent_analytics.py then reads its config from here.
# app/privacy.py
from amplitude_ai import AIConfig
CONTENT_MODE = "full"
def build_config(**overrides):
return AIConfig(
content_mode=CONTENT_MODE,
redact_pii=True,
**overrides,
)# app/agent_analytics.py
# Keep lines other Amplitude Build pages added here: the wrapped OpenAI and
# AsyncOpenAI clients and support_agent (/build/fastapi-agent-analytics,
# /build/django-agent-analytics). Only the config= line changes.
import os
from amplitude_ai import AmplitudeAI
from .privacy import build_config
ai = AmplitudeAI(
api_key=os.environ["AMPLITUDE_AI_API_KEY"],
config=build_config(debug=os.environ.get("AMPLITUDE_AI_DEBUG") == "true"),
)Now the test. Review mode records each event after redaction and prints a sanitized upload preview, and it sends nothing. The test runs one session with fake PII through it and checks the preview.
# tests/test_redaction.py
from amplitude_ai import AmplitudeAI
from app.privacy import build_config
USER_MESSAGE = (
"Hi, I'm Jane Doe. Email me at jane.doe@example.com or call "
"(415) 555-0134. Card 4111 1111 1111 1111, SSN 123-45-6789, "
"logged in from 203.0.113.7."
)
AI_MESSAGE = "Thanks. I've sent the refund details to jane.doe@example.com."
RAW_VALUES = [
"jane.doe@example.com",
"555-0134",
"4111 1111 1111 1111",
"123-45-6789",
"203.0.113.7",
]
PLACEHOLDERS = ["[email]", "[phone]", "[credit_card]", "[ssn]", "[ip_address]"]
def outbound_preview():
ai = AmplitudeAI(
api_key="review-mode-only", # review mode never sends
config=build_config(review_mode=True),
)
agent = ai.agent("redaction-test")
with agent.session(user_id="test-user-1", session_id="redaction-test-1") as s:
s.track_user_message(USER_MESSAGE)
s.track_ai_message(AI_MESSAGE, "gpt-4o-mini", "openai", 120)
ai.flush()
preview = str(ai.get_review_traffic())
ai.shutdown()
return preview
def test_no_raw_pii_leaves_the_process():
preview = outbound_preview()
for raw in RAW_VALUES:
assert raw not in preview, f"{raw!r} would leave the process"
for placeholder in PLACEHOLDERS:
assert placeholder in preview, f"expected {placeholder} in the upload"Install pytest with uv add --dev pytest, then run uv run python -m pytest tests/test_redaction.py. Every value in the message is one the built-in patterns cover. Notice what's still in the preview: "Jane Doe". Detection is pattern-based, with no named-entity model, so names, street addresses, and dates of birth pass through. Steps 3 and 4 close that gap, and the test grows with them.
The Node.js version of the same setup uses reviewMode and getReviewTraffic():
// lib/privacy.js
import { AIConfig } from '@amplitude/ai';
export const CONTENT_MODE = 'full';
export function buildConfig(overrides = {}) {
return new AIConfig({
contentMode: CONTENT_MODE,
redactPii: true,
...overrides,
});
}// test/redaction.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AmplitudeAI } from '@amplitude/ai';
import { buildConfig } from '../lib/privacy.js';
const USER_MESSAGE =
"Hi, I'm Jane Doe. Email me at jane.doe@example.com or call " +
'(415) 555-0134. Card 4111 1111 1111 1111, SSN 123-45-6789, ' +
'logged in from 203.0.113.7.';
const AI_MESSAGE = "Thanks. I've sent the refund details to jane.doe@example.com.";
const RAW_VALUES = ['jane.doe@example.com', '555-0134', '4111 1111 1111 1111', '123-45-6789', '203.0.113.7'];
const PLACEHOLDERS = ['[email]', '[phone]', '[credit_card]', '[ssn]', '[ip_address]'];
test('no raw PII leaves the process', async () => {
const ai = new AmplitudeAI({
apiKey: 'review-mode-only', // review mode never sends
config: buildConfig({ reviewMode: true }),
});
const agent = ai.agent('redaction-test');
await agent.session({ userId: 'test-user-1', sessionId: 'redaction-test-1' }).run(async (s) => {
s.trackUserMessage(USER_MESSAGE);
s.trackAiMessage(AI_MESSAGE, 'gpt-4o-mini', 'openai', 120);
});
const pending = ai.flush();
await (pending?.promise ?? pending);
const preview = JSON.stringify(ai.getReviewTraffic());
for (const raw of RAW_VALUES) assert.ok(!preview.includes(raw), `${raw} would leave the process`);
for (const p of PLACEHOLDERS) assert.ok(preview.includes(p), `expected ${p} in the upload`);
});In lib/amplitude-ai.js, replace the inline new AIConfig({...}) with buildConfig({ debug: process.env.NODE_ENV !== 'production' }). Run the test with node --test.
Why. The test asserts on the post-redaction preview rather than on your scrubbing code, so it catches a config regression such as redact_pii=False, or a change in content handling, as well as a pattern miss. The placeholders contain no quotes or backslashes, so a plain substring check works on the serialized preview. Previews keep the redacted message content, so don't commit their output as a fixture.
3. Add patterns for your own identifiers
The built-in patterns know nothing about your domain. Customer IDs, account numbers, and order references are often the most sensitive strings in a support conversation. Add them with custom_redaction_patterns, which takes regex strings and replaces each match with [REDACTED]. Custom patterns run after the built-in ones.
# app/privacy.py
from amplitude_ai import AIConfig
CONTENT_MODE = "full"
# Each regex match becomes [REDACTED]. Runs after the built-in patterns.
CUSTOM_PATTERNS = [
r"\bCUST-\d{6}\b", # internal customer IDs
r"\b3[47]\d{2}[ -]?\d{6}[ -]?\d{5}\b", # American Express card numbers
]
def build_config(**overrides):
return AIConfig(
content_mode=CONTENT_MODE,
redact_pii=True,
custom_redaction_patterns=CUSTOM_PATTERNS,
**overrides,
)The second pattern is there because the built-in card pattern matches 16 digits in groups of four. American Express numbers are 15 digits that start with 34 or 37, printed in groups of 4, 6, and 5, so they don't fit that shape. Swap CUST-\d{6} for the format of your own IDs.
Add the new values to the test:
# tests/test_redaction.py (change these lines)
USER_MESSAGE = (
"Hi, I'm Jane Doe. Email me at jane.doe@example.com or call "
"(415) 555-0134. Card 4111 1111 1111 1111, SSN 123-45-6789, "
"logged in from 203.0.113.7. My account is CUST-004211 and my "
"other card is 3714 496353 98431."
)
RAW_VALUES = [
"jane.doe@example.com",
"555-0134",
"4111 1111 1111 1111",
"123-45-6789",
"203.0.113.7",
"CUST-004211",
"3714 496353 98431",
]Leave PLACEHOLDERS as it is. Review mode masks the upload's own API key as [REDACTED], so a [REDACTED] check would pass even if your patterns never ran. The two new RAW_VALUES entries are what prove they did.
In Node.js, the option is customRedactionPatterns. It also accepts { pattern, replacement } objects when you'd rather write a named label such as [customer_id] than [REDACTED].
// lib/privacy.js
export const CUSTOM_PATTERNS = [
'\\bCUST-\\d{6}\\b',
'\\b3[47]\\d{2}[ -]?\\d{6}[ -]?\\d{5}\\b',
];
// inside buildConfig, next to redactPii: true
// customRedactionPatterns: CUSTOM_PATTERNS,Add 'CUST-004211' and '3714 496353 98431' to the Node.js test's message and RAW_VALUES.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event and property taxonomy, so the context keys it adds in step 6 match dimensions you already chart instead of inventing new ones. Set up the MCP server.
4. Add named-entity redaction with a scrubber
Names, locations, and dates need more than a fixed format match. To add a named-entity model, pass custom_redaction_fn, a (text) -> str callback that runs after all regex-based redaction. The docs name Presidio as one scrubber you can plug in; it runs a named-entity model on your own infrastructure.
uv add presidio-analyzer presidio-anonymizer
uv add "https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-3.8.0/en_core_web_lg-3.8.0-py3-none-any.whl"# app/privacy.py
from amplitude_ai import AIConfig
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
CONTENT_MODE = "full"
CUSTOM_PATTERNS = [
r"\bCUST-\d{6}\b", # internal customer IDs
r"\b3[47]\d{2}[ -]?\d{6}[ -]?\d{5}\b", # American Express card numbers
]
# Build the engines once. Loading the language model per call is slow.
_analyzer = AnalyzerEngine()
_anonymizer = AnonymizerEngine()
NER_ENTITIES = ["PERSON", "LOCATION", "DATE_TIME"]
def scrub_entities(text: str) -> str:
results = _analyzer.analyze(text=text, entities=NER_ENTITIES, language="en")
return _anonymizer.anonymize(text=text, analyzer_results=results).text
def build_config(**overrides):
return AIConfig(
content_mode=CONTENT_MODE,
redact_pii=True,
custom_redaction_patterns=CUSTOM_PATTERNS,
custom_redaction_fn=scrub_entities,
**overrides,
)Presidio replaces recognized entities with their type, so the sample "Jane Doe" becomes <PERSON>. Detection depends on the language model and context. Add representative names, addresses, and dates from your application to the test; the three entity labels do not guarantee every sensitive value is detected. Add "Jane Doe" to RAW_VALUES in the test and "<PERSON>" to PLACEHOLDERS.
DATE_TIME can detect dates of birth and scheduling phrases such as "tomorrow" or "next Tuesday". Drop it from NER_ENTITIES if your agent's quality depends on scheduling language and dates of birth aren't something your users type.
Why. If scrub_entities raises or returns something other than a string, the SDK replaces the whole value with [content redacted]. A bug in your scrubber hides the message instead of leaking it, which is the failure you want, but it also blanks the text that signals read. Watch for [content redacted] after a deploy (see Troubleshooting).
In Node.js, the option is customRedactionFn, a synchronous (text) => string callback that runs after the regex patterns. Pass it in buildConfig next to customRedactionPatterns, wrapping whichever named-entity library you use in Node.js.
5. Choose how much content to send
Redaction decides how text leaves. The privacy mode decides whether it leaves at all. Every mode still sends tokens, cost, latency, model names, and session grouping. Choose the mode before wide rollout, because it decides what your legal review needs to cover.
full(default). What leaves your process: Message text, system prompts, tool inputs and outputs, score comments, all after redaction What you lose: Text-based signals, topics, and LLM evaluators can run on the redacted text.metadata_only. What leaves your process: No content What you lose: Built-in signals and evaluators that read text leave their results off the[Agent] Session Record. Data Quality Issues and code evaluators still run.customer_enriched. What leaves your process: No content What you lose: The same asmetadata_only, but you run your own evaluation and send the labels back withtrack_session_enrichment().
Amplitude recommends full with redaction on. If you pick another mode, change one line:
# app/privacy.py (change this line)
CONTENT_MODE = "metadata_only" # or "customer_enriched"In Node.js, change CONTENT_MODE in lib/privacy.js the same way. In metadata_only, the gate also covers exception messages and stack traces recorded by observe() and OpenTelemetry error paths, and framework attributes such as gen_ai.input.messages. If you run zero-code auto-instrumentation instead of AmplitudeAI, the setting is the AMPLITUDE_AI_CONTENT_MODE environment variable.
In metadata_only and customer_enriched, no text reaches the preview, so the placeholder assertions in the test fail. Delete the PLACEHOLDERS loop and keep the RAW_VALUES loop, which still proves nothing raw leaves.
With customer_enriched, score the raw conversation on your own infrastructure and send the result. The enrichment lands on an [Agent] Session Enrichment event:
# app/enrich.py
from amplitude_ai import SessionEnrichments
from .agent_analytics import support_agent
def send_quality_labels(user_id, session_id, eval_results):
enrichments = SessionEnrichments(
quality_score=eval_results.quality,
overall_outcome=eval_results.outcome, # for example "resolved"
)
support_agent.track_session_enrichment(
enrichments, user_id=user_id, session_id=session_id,
)user_id must be the same stable identity used for the session. eval_results stands for your own pipeline's output. For every field SessionEnrichments accepts, see Provide your own session enrichments.
For managed-agent architectures, review both the managed API's storage and the analytics payload before choosing your content mode.
6. Keep PII out of the fields redaction doesn't scan
Redaction runs on the content channels listed in step 1, inside a process that loads the AI SDK. A few paths sit outside that.
The user ID isn't content. Pass your internal account ID as user_id, never an email address or phone number, because no pattern touches it. Keep context= and message labels free of PII too. In full mode, context values are forwarded unchanged; keep them to segmentation values such as surface or plan.
# Do: segmentation dimensions in context
s.track_user_message(text, context={"surface": "chat", "plan": "pro"})
# Don't: customer data in context
s.track_user_message(text, context={"customer_email": user.email})Two debug options carry their own risk. capture_stack_trace (captureStackTrace in Node.js) attaches tracebacks to error events and defaults to off, because stack traces can contain file paths and internal details. And in Python, enable_otel(otel_span_filter="all") sends HTTP and database spans too, whose names can contain user data, so review them before you turn it on.
If you send agent events any other way, through the HTTP API, Batch, S3, OpenTelemetry, or a warehouse import, Amplitude doesn't scrub content after receipt. Mask the text, drop it, or run your own callback, SQL, or Collector processor before it leaves your infrastructure. The SDK's patterns are published in the privacy guide so you can reuse them.
For an EU project, configure the analytics client for the EU and pass it in. The privacy mode and redaction apply the same way:
# app/agent_analytics.py (EU projects only)
import os
from amplitude import Amplitude, Config
from amplitude_ai import AmplitudeAI
from .privacy import build_config
client = Amplitude(os.environ["AMPLITUDE_AI_API_KEY"], Config(server_zone="EU"))
ai = AmplitudeAI(
amplitude=client,
config=build_config(debug=os.environ.get("AMPLITUDE_AI_DEBUG") == "true"),
)Verify it worked
Run the test, then start your app and send a message with fake PII in it, such as "I'm Jane Doe, jane.doe@example.com, card 4111 1111 1111 1111". Check in this order:
- Test output.
uv run python -m pytest tests/test_redaction.py(ornode --test) passes. Review mode sends nothing, so the test makes no upload to Amplitude. - Terminal. With
AMPLITUDE_AI_DEBUG=true, each tracked event prints a one-line summary to stderr. If nothing prints, the model call ran outside a session and nothing was tracked, redacted or not. - Session viewer. In Amplitude, open agent analytics and find the session. The message text shows
[email],[credit_card], and<PERSON>where your values were. Inmetadata_onlyorcustomer_enriched, it shows no message text at all. - User lookup. Go to Users > User Profiles, search your test user ID, and open the
[Agent] User Messageevent. Its message text carries the same placeholders.
Troubleshooting
Raw emails and phone numbers arrive in Amplitude
You're on Python amplitude-ai below 1.17.0, where redact_pii defaults to False, or something passes redact_pii=False. Upgrade, and build every AIConfig through build_config() so the flag can't drift.
Messages show [content redacted]
Your custom_redaction_fn raised or returned a non-string, and the SDK replaced the whole value. Call scrub_entities() directly on the same text to see the error. The Presidio language model missing from the environment is a common cause.
Order numbers or IDs show up as [phone]
Any 10-digit number in a US phone layout matches the phone pattern, and the pattern can't tell an order number from a phone number. If you need the ID in your analysis, send it as a context dimension rather than relying on the message text.
International phone numbers pass through
Phone detection targets US formats. International numbers only match in E.164 form with a leading +. Add a custom_redaction_patterns entry for the local formats your users type.
Task Completed and Response Quality disappeared from Session Records
You switched to metadata_only or customer_enriched. Those signals read message text, so Amplitude leaves them off rather than guessing. Send your own labels with track_session_enrichment(), or go back to full with redaction.
The test fails on placeholders after a mode change
No text leaves in metadata_only or customer_enriched, so there's nothing to replace. Keep the raw-value assertions and remove the placeholder loop.
Full code reference
The privacy modules and redaction tests together. Keep the existing wrapped clients and agents in your analytics module. The optional enrichment function is in step 5.
# -- app/privacy.py --
from amplitude_ai import AIConfig
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
CONTENT_MODE = "full"
# Each regex match becomes [REDACTED]. Runs after the built-in patterns.
CUSTOM_PATTERNS = [
r"\bCUST-\d{6}\b", # internal customer IDs
r"\b3[47]\d{2}[ -]?\d{6}[ -]?\d{5}\b", # American Express card numbers
]
_analyzer = AnalyzerEngine()
_anonymizer = AnonymizerEngine()
NER_ENTITIES = ["PERSON", "LOCATION", "DATE_TIME"]
def scrub_entities(text: str) -> str:
results = _analyzer.analyze(text=text, entities=NER_ENTITIES, language="en")
return _anonymizer.anonymize(text=text, analyzer_results=results).text
def build_config(**overrides):
return AIConfig(
content_mode=CONTENT_MODE,
redact_pii=True,
custom_redaction_patterns=CUSTOM_PATTERNS,
custom_redaction_fn=scrub_entities,
**overrides,
)
# -- app/agent_analytics.py (the client; keep the wrapped clients and agent below it) --
import os
from amplitude_ai import AmplitudeAI
from .privacy import build_config
ai = AmplitudeAI(
api_key=os.environ["AMPLITUDE_AI_API_KEY"],
config=build_config(debug=os.environ.get("AMPLITUDE_AI_DEBUG") == "true"),
)
# -- tests/test_redaction.py --
from amplitude_ai import AmplitudeAI
from app.privacy import build_config
USER_MESSAGE = (
"Hi, I'm Jane Doe. Email me at jane.doe@example.com or call "
"(415) 555-0134. Card 4111 1111 1111 1111, SSN 123-45-6789, "
"logged in from 203.0.113.7. My account is CUST-004211 and my "
"other card is 3714 496353 98431."
)
AI_MESSAGE = "Thanks. I've sent the refund details to jane.doe@example.com."
RAW_VALUES = [
"jane.doe@example.com",
"555-0134",
"4111 1111 1111 1111",
"123-45-6789",
"203.0.113.7",
"CUST-004211",
"3714 496353 98431",
"Jane Doe",
]
PLACEHOLDERS = [
"[email]", "[phone]", "[credit_card]", "[ssn]", "[ip_address]",
"<PERSON>",
]
def outbound_preview():
ai = AmplitudeAI(
api_key="review-mode-only", # review mode never sends
config=build_config(review_mode=True),
)
agent = ai.agent("redaction-test")
with agent.session(user_id="test-user-1", session_id="redaction-test-1") as s:
s.track_user_message(USER_MESSAGE)
s.track_ai_message(AI_MESSAGE, "gpt-4o-mini", "openai", 120)
ai.flush()
preview = str(ai.get_review_traffic())
ai.shutdown()
return preview
def test_no_raw_pii_leaves_the_process():
preview = outbound_preview()
for raw in RAW_VALUES:
assert raw not in preview, f"{raw!r} would leave the process"
for placeholder in PLACEHOLDERS:
assert placeholder in preview, f"expected {placeholder} in the upload"// -- lib/privacy.js --
import { AIConfig } from '@amplitude/ai';
export const CONTENT_MODE = 'full';
export const CUSTOM_PATTERNS = [
'\\bCUST-\\d{6}\\b',
'\\b3[47]\\d{2}[ -]?\\d{6}[ -]?\\d{5}\\b',
];
export function buildConfig(overrides = {}) {
return new AIConfig({
contentMode: CONTENT_MODE,
redactPii: true,
customRedactionPatterns: CUSTOM_PATTERNS,
// customRedactionFn: (text) => yourScrubber(text),
...overrides,
});
}
// -- lib/amplitude-ai.js (the client; keep the wrapped client and agent below it) --
// Keep lines other Amplitude Build pages added here: the wrapped OpenAI client
// and the agent (/build/nodejs-agent-analytics).
import { AmplitudeAI } from '@amplitude/ai';
import { buildConfig } from './privacy.js';
const apiKey = process.env.AMPLITUDE_AI_API_KEY;
if (!apiKey) {
throw new Error('AMPLITUDE_AI_API_KEY is not set');
}
export const ai = new AmplitudeAI({
apiKey,
config: buildConfig({ debug: process.env.NODE_ENV !== 'production' }),
});
// -- test/redaction.test.js --
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AmplitudeAI } from '@amplitude/ai';
import { buildConfig } from '../lib/privacy.js';
const USER_MESSAGE =
"Hi, I'm Jane Doe. Email me at jane.doe@example.com or call " +
'(415) 555-0134. Card 4111 1111 1111 1111, SSN 123-45-6789, ' +
'logged in from 203.0.113.7. My account is CUST-004211 and my ' +
'other card is 3714 496353 98431.';
const AI_MESSAGE = "Thanks. I've sent the refund details to jane.doe@example.com.";
const RAW_VALUES = [
'jane.doe@example.com',
'555-0134',
'4111 1111 1111 1111',
'123-45-6789',
'203.0.113.7',
'CUST-004211',
'3714 496353 98431',
];
const PLACEHOLDERS = ['[email]', '[phone]', '[credit_card]', '[ssn]', '[ip_address]'];
test('no raw PII leaves the process', async () => {
const ai = new AmplitudeAI({
apiKey: 'review-mode-only', // review mode never sends
config: buildConfig({ reviewMode: true }),
});
const agent = ai.agent('redaction-test');
await agent.session({ userId: 'test-user-1', sessionId: 'redaction-test-1' }).run(async (s) => {
s.trackUserMessage(USER_MESSAGE);
s.trackAiMessage(AI_MESSAGE, 'gpt-4o-mini', 'openai', 120);
});
const pending = ai.flush();
await (pending?.promise ?? pending);
const preview = JSON.stringify(ai.getReviewTraffic());
for (const raw of RAW_VALUES) assert.ok(!preview.includes(raw), `${raw} would leave the process`);
for (const p of PLACEHOLDERS) assert.ok(preview.includes(p), `expected ${p} in the upload`);
});Hand it to your agent
Prefer to have Cursor, Claude Code, or Codex CLI make the change? This prompt points the agent at this page and pins the decisions that matter.
Add PII redaction to this app's Amplitude agent analytics by following
https://amplitude.com/build/redact-pii-agent-analytics exactly. In Python,
require amplitude-ai>=1.20.1 and @amplitude/ai>=0.24.1 in Node.js. Create app/privacy.py (lib/privacy.js in
Node.js) with a build_config() / buildConfig() that sets content_mode
"full" and redact_pii=True, adds custom_redaction_patterns for our own
customer and account ID formats (ask me for them; don't guess), and, in
Python, a custom_redaction_fn that runs Presidio for PERSON, LOCATION and
DATE_TIME. Make the existing AmplitudeAI client read its config from
build_config(); keep every other line in that module, including the
wrapped clients and agents other Amplitude Build pages added. Add a test
that runs one session with fake PII under review_mode=True / reviewMode:
true and fails if any raw value appears in get_review_traffic() /
getReviewTraffic(). Never pass an email or phone number as user_id or
inside context or labels. Don't change the privacy mode from "full"
without asking me. Report every file you changed and the test result.What this unlocks
- Agent analytics still runs Task Completed and Response Quality on redacted text in
fullmode, so you keep the quality signals without the card numbers. - Funnels in product analytics from
[Agent] User MessagetoPlan Upgradedwork in every mode, because redaction changes content, not the user ID. - Feature Experiment can compare two prompts on
[Agent] Cost USDand latency even inmetadata_only. - Guides and surveys can reach users whose
[Agent] Session Recordshows[Agent] Has User Frictiontrue, as long as you send redacted text infull.
FAQ
Does redaction change the model response shown to the user?
The SDK applies these settings to the analytics event content. The application still receives the provider response.
Does metadata-only mode anonymize user IDs?
No. Content mode controls message content. Use a stable internal ID rather than an email or phone number for user identity and segmentation fields.