BrowserStack AI Evals
Tracing

PII Masking

Redact personally identifiable information from traces before they leave your process using a customer-supplied mask function.

PII Masking

The SDK lets you supply a single function that is called on every input, output, metadata, and retrieved-context field — on traces, spans, generations, and events — before any data is exported. For auto-instrumented LLM calls the same function is applied to the captured input, output, metadata, and retrieved-context content.

The SDK does not walk or transform values on your behalf. Your function receives each field as-is and your code owns recursion, regex matching, and any external PII-detection library you want to bring in.

Quick start

import { AISDK } from '@browserstack/ai-sdk';

function mask(data: unknown): unknown {
  if (typeof data === 'string') {
    return data
      .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[EMAIL]')
      .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]');
  }
  if (Array.isArray(data)) return data.map(mask);
  if (data !== null && typeof data === 'object') {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(data)) out[k] = mask(v);
    return out;
  }
  return data;
}

const client = new AISDK({
  publicKey: process.env.AISDK_PUBLIC_KEY,
  secretKey: process.env.AISDK_SECRET_KEY,
  mask,
});

The same mask option is accepted by Observe.init():

import { Observe } from '@browserstack/ai-sdk';

await Observe.init({
  publicKey: process.env.AISDK_PUBLIC_KEY,
  secretKey: process.env.AISDK_SECRET_KEY,
  mask,
});
import os
import re
from typing import Any
from browserstack_ai_sdk import AISDK, Observe

def mask(data: Any) -> Any:
    if isinstance(data, str):
        data = re.sub(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b', '[EMAIL]', data)
        data = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN]', data)
        return data
    if isinstance(data, list):
        return [mask(item) for item in data]
    if isinstance(data, dict):
        return {k: mask(v) for k, v in data.items()}
    return data

# With AISDK client
client = AISDK(
    public_key=os.environ["AISDK_PUBLIC_KEY"],
    secret_key=os.environ["AISDK_SECRET_KEY"],
    mask=mask,
)

# Or with Observe.init for auto-instrumentation
Observe.init(
    public_key=os.environ["AISDK_PUBLIC_KEY"],
    secret_key=os.environ["AISDK_SECRET_KEY"],
    mask=mask,
)
import com.browserstack.aisdk.TestOps;
import com.browserstack.aisdk.pii.CustomScrubFn;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

private static final Pattern EMAIL =
    Pattern.compile("\\b[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}\\b");
private static final Pattern SSN =
    Pattern.compile("\\b\\d{3}-\\d{2}-\\d{4}\\b");

@SuppressWarnings("unchecked")
CustomScrubFn mask = data -> {
    if (data instanceof String) {
        String s = (String) data;
        s = EMAIL.matcher(s).replaceAll("[EMAIL]");
        s = SSN.matcher(s).replaceAll("[SSN]");
        return s;
    }
    if (data instanceof List) {
        return ((List<Object>) data).stream()
            .map(mask::apply)
            .collect(Collectors.toList());
    }
    if (data instanceof Map) {
        return ((Map<String, Object>) data).entrySet().stream()
            .collect(Collectors.toMap(
                Map.Entry::getKey,
                e -> mask.apply(e.getValue())
            ));
    }
    return data;
};

TestOps sdk = TestOps.builder()
    .publicKey(System.getenv("AISDK_PUBLIC_KEY"))
    .secretKey(System.getenv("AISDK_SECRET_KEY"))
    .mask(mask)
    .build();

What the SDK masks

Manual tracing

When you call client.trace(), trace.generation(), trace.span(), or trace.event(), the mask function is applied to:

FieldMasked
inputYes
outputYes
metadataYes
retrievedContexts (retrieved_contexts in Python)Yes — retrieved context attached to a trace
nameNo — set by your instrumentation code, not expected to carry PII
IDs, timestamps, model namesNo

Masking applies at creation time and again on every .update() and .end() call, so late-set fields are covered too.

Auto-instrumentation

For providers traced automatically (OpenAI, Anthropic, LangChain, and others), the mask function is applied to the same content categories as manual tracing before the data is exported: the call's input (prompts, messages, and tool definitions), its output (model completions/responses), any metadata, and retrieved/RAG context. Content captured as a structured value (a list of messages, for example) is passed to your function as that structure, not as an opaque string. Token counts, model names, timestamps, and IDs are never passed to the mask function.

Writing your mask function

Signature

type CustomScrubFn = (data: unknown) => unknown;
from typing import Any, Callable

MaskFunction = Callable[[Any], Any]
@FunctionalInterface
public interface CustomScrubFn {
    Object apply(Object data);
}

The SDK does not walk values for you

Your function receives each field value whole — an entire input object, an entire metadata dict. If you only match strings, nested strings inside objects will not be redacted unless your function recurses. The quick-start examples above show a simple recursive walker.

This is intentional: you know the shape of your data and can target exactly what needs redacting. The SDK does not impose a traversal strategy on you.

Fail-closed error handling

If your mask function throws an exception, the SDK replaces the field value with the string "[MASKED_ERROR]" rather than letting the original value through. A buggy mask cannot silently leak data.

// A throwing mask → the exported field value becomes "[MASKED_ERROR]"
const client = new AISDK({
  publicKey: process.env.AISDK_PUBLIC_KEY,
  secretKey: process.env.AISDK_SECRET_KEY,
  mask: (_data) => { throw new Error('unexpected shape'); },
});

const trace = client.trace({
  name: 'my-trace',
  input: 'sensitive value',
  // exported input will be "[MASKED_ERROR]", not "sensitive value"
});
def bad_mask(data):
    raise ValueError("unexpected shape")

client = AISDK(
    public_key=os.environ["AISDK_PUBLIC_KEY"],
    secret_key=os.environ["AISDK_SECRET_KEY"],
    mask=bad_mask,
)
# exported input / output / metadata become "[MASKED_ERROR]"
CustomScrubFn badMask = data -> {
    throw new RuntimeException("unexpected shape");
};

TestOps sdk = TestOps.builder()
    .mask(badMask)
    .build();
// exported input / output / metadata become "[MASKED_ERROR]"

Null and undefined values are never passed to your function

The SDK skips the mask call entirely when a field is null or undefined / None. Your function will only receive actual values.

Validating the mask option

Passing a non-callable value for mask throws an error at construction time — not silently at trace time.

// TypeScript rejects a non-function `mask` at compile time. For values typed
// as `any` or passed from JavaScript, the constructor is the backstop — it
// throws: TypeError: AISDKConfig.mask must be a function: (data) => maskedData.
new AISDK({
  publicKey: 'pk-...',
  secretKey: 'sk-...',
  mask: { commonPII: true }, // compile error: not assignable to CustomScrubFn
});
# Raises TypeError: AISDKConfig.mask must be a callable (data) -> masked_data.
AISDK(
    public_key="pk-...",
    secret_key="sk-...",
    mask="not-a-function",  # type: ignore
)
// The type system prevents passing a non-callable — CustomScrubFn is a
// @FunctionalInterface and the builder only accepts that type.