# Redact Sensitive Data from Telemetry

> **Scope of this page**
>
> This page documents the Zuplo **gateway** runtime (API, AI, and MCP gateways), not the Developer Portal.
>
> It applies to every Zuplo hosting option: Managed Edge, Managed Dedicated, and Self-Hosted. See [Hosting options](https://zuplo.com/docs/articles/hosting-options).

Requests that pass through your gateway can carry sensitive data such as card
numbers, government IDs, and health data. Zuplo records values from each request
in logs, traces, analytics, and audit events. A telemetry redactor removes
sensitive data from those values before Zuplo records them.

Register a redactor once in `zuplo.runtime.ts`, and every log entry, span,
analytics record, request logger entry, and audit event passes through it.
Redaction applies only to what Zuplo records. The request, the response, and the
values your code reads stay unchanged.

```ts title="modules/zuplo.runtime.ts"
import { RuntimeExtensions, createRedactor } from "@zuplo/runtime";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addTelemetryRedactor(
    createRedactor({
      entities: ["pii", "secret"],
      queryParameters: ["iban"],
      fieldNames: ["dob"],
    }),
  );
}
```

With this redactor, a request to `/accounts?iban=DE89370400440532013000&page=2`
whose handler calls
`context.log.info("charging", { card: "4111 1111 1111 1111", dob: "1990-01-01" })`
is recorded like this:

```diff
- "url": "https://api.example.com/accounts?iban=DE89370400440532013000&page=2"
+ "url": "https://api.example.com/accounts?iban=%5BREDACTED%5D&page=2"
- "messages": ["charging", { "card": "4111 1111 1111 1111", "dob": "1990-01-01" }]
+ "messages": ["charging", { "card": "[REDACTED]", "dob": "[REDACTED]" }]
```

The response to the client is the same with or without the redactor.

## Register a redactor

Call `runtime.addTelemetryRedactor` inside `runtimeInit` in
[`zuplo.runtime.ts`](./runtime-extensions.mdx). A redactor is either the result
of [`createRedactor`](#use-the-built-in-redactor) or your own
[function](#write-a-custom-redactor).

- Register redactors only in `runtimeInit`. Calling `addTelemetryRedactor` after
  the gateway starts, for example inside a request hook, throws an error and
  doesn't add the redactor.
- You can register up to 16 redactors. Registering a 17th throws an error.
- Passing anything other than a function throws an error.

An error thrown in `runtimeInit` stops the gateway from starting, so a
misconfigured redactor never lets data through unredacted.

### Combine several redactors

Redactors run in the order you register them. Each redactor receives the output
of the previous one for the same value.

```ts title="modules/zuplo.runtime.ts"
import { RuntimeExtensions, createRedactor } from "@zuplo/runtime";

export function runtimeInit(runtime: RuntimeExtensions) {
  // Runs first: built-in patterns
  runtime.addTelemetryRedactor(
    createRedactor({ entities: ["finance-credit-card", "id-us-ssn"] }),
  );
  // Runs second: receives the output of the first redactor
  runtime.addTelemetryRedactor((value, { key }) =>
    key === "accountNumber" ? "[REDACTED]" : value,
  );
}
```

## Use the built-in redactor

`createRedactor(options)` returns a redactor that applies only the options you
set. It detects sensitive values with the same built-in data types and groups as
the [Data Loss Prevention policy](../policies/ai-gateway-dlp-inbound.mdx), so
`entities: ["pii", "secret"]` selects the same data types in both places.

| Option            | What it redacts                                                                                                                                                                                              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `entities`        | Matches of built-in data types by ID (`finance-credit-card`, `id-us-ssn`), group (`pii`, `secret`, `region-eu`), or ID prefix (`id-us`, `secret-aws`). When omitted, no built-in data type runs.             |
| `customPatterns`  | Matches of your own regular expressions. Each entry is `{ name, pattern, confidence?, context? }`.                                                                                                           |
| `minConfidence`   | The confidence, from 0 to 1, a match from `entities` or `customPatterns` needs to be redacted. Defaults to `0.5`.                                                                                            |
| `allowValues`     | Matched text that's never redacted, compared exactly, for example a test card number.                                                                                                                        |
| `queryParameters` | The value of each named query parameter wherever a query string appears: request URLs, span attributes such as `url.full`, and URLs inside log messages. Names match case-insensitively, after URL decoding. |
| `fieldNames`      | The whole value of any property whose name is in the list, for example `["password", "dob"]`. Names match case-insensitively.                                                                                |
| `replacement`     | The text recorded in place of a match. It's used literally. Defaults to `[REDACTED]`. In query parameter values, it's URL-encoded, so `[REDACTED]` is recorded as `%5BREDACTED%5D` and the URL stays valid.  |

`createRedactor` throws an error in `runtimeInit` for an unknown entity, a
custom pattern that isn't a valid regular expression, or a `minConfidence`
outside 0 to 1. A typo stops the gateway from starting instead of silently
turning redaction off.

`fieldNames` redacts a value only when it sits directly under the matching
property name. In `{ dob: "1990-01-01" }` the value is redacted, but in
`{ dob: ["1990-01-01"] }` or `{ card: { number: "..." } }` the nested values are
checked only by the other options. Property names themselves are recorded as
written.

### Custom patterns

A custom pattern has a `name`, a regular expression `pattern`, an optional base
`confidence` (defaults to `0.85`), and optional `context` words. Context words
that appear near a match in the same value raise its confidence. Use a low
`confidence` with `context` for values that are sensitive only in context.

```ts title="modules/zuplo.runtime.ts"
import { RuntimeExtensions, createRedactor } from "@zuplo/runtime";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addTelemetryRedactor(
    createRedactor({
      entities: ["pii"],
      customPatterns: [
        // Always redacted: confidence 0.85 is above the 0.5 default
        { name: "member-id", pattern: "MBR-\\d{8}" },
        // Redacted only when "patient" or "mrn" appears nearby
        {
          name: "medical-record-number",
          pattern: "\\b\\d{7}\\b",
          confidence: 0.3,
          context: ["patient", "mrn"],
        },
      ],
      allowValues: ["4111 1111 1111 1111"],
    }),
  );
}
```

:::note

Pattern matching can't find every form sensitive data takes. Add a
[custom redactor](#write-a-custom-redactor) for data that's specific to your
API.

:::

## Write a custom redactor

A redactor is a synchronous function that takes a string and a context and
returns the string to record: the input unchanged, or a redacted copy.

```ts
interface TelemetryRedactor {
  (value: string, context: RedactionContext): string;
}

interface RedactionContext {
  readonly kind: "log" | "span" | "analytics" | "request-log" | "audit-log";
  readonly field: string;
  readonly key: string | undefined;
}
```

| Property | Description                                                                                                                                                               |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind`   | The kind of record the value is written to. See [What's redacted](#whats-redacted).                                                                                       |
| `field`  | The path of the value in the record, for example `message[0]`, `message[1].user.email`, `properties.customerId`, `attributes.url.full`, or `url`.                         |
| `key`    | The last property name in `field`, for example `email` or `url.full`. `undefined` when the value isn't an object property, such as a log message string or array element. |

For example, `context.log.info("charging", { card: "4111 1111 1111 1111" })`
calls the redactor twice:

| `value`                 | `kind` | `field`           | `key`       |
| ----------------------- | ------ | ----------------- | ----------- |
| `"charging"`            | `log`  | `message[0]`      | `undefined` |
| `"4111 1111 1111 1111"` | `log`  | `message[1].card` | `card`      |

The `url.full` attribute on a span is passed with `kind: "span"`,
`field: "attributes.url.full"`, and `key: "url.full"`.

Use the context to apply a rule to one kind of record or one property:

```ts title="modules/zuplo.runtime.ts"
import { RuntimeExtensions } from "@zuplo/runtime";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addTelemetryRedactor((value, { kind, key }) => {
    if (key === "ssn") {
      return "[REDACTED]";
    }
    if (kind === "audit-log" && key === "email") {
      return value.replace(/^[^@]+/, "***");
    }
    return value;
  });
}
```

### How values are passed

- Objects, arrays, and errors are walked the way the log serializers write them,
  and each string inside is passed to the redactor separately. For an error,
  that includes its message, stack, cause, and custom properties. A `Map` or
  `Set` is recorded the way the serializers print it, usually `{}`.
- Numbers are passed as their decimal string. If the redactor returns the same
  string, the record keeps the number. If it returns a different string, the
  record holds that string. Dates work the same way, using their serialized
  string.
- A span attribute array must hold a single type. If the redactor changes any
  number in such an array, every element is recorded as text.
- When the redacted request URL given to log transports is no longer a valid
  URL, transports receive the original origin followed by `/[REDACTED]`.
- In audit events, the IP address is left out of the event when the redactor
  changes it.

### Keep redactors pure and fast

A redactor runs on the request path for every value Zuplo records, so keep it
fast. It must also be pure: the same value and context must always return the
same output. Zuplo caches results for values of 256 characters or fewer and can
reuse an earlier result instead of calling the redactor again.

Anything a redactor logs is dropped, so a redactor can't write sensitive values
into the logs it's meant to protect.

## Failures

Redaction fails closed. When a redactor throws an error, returns anything other
than a string, or is `async` (returns a promise), Zuplo records the value as
`[REDACTION_FAILED]`. Later redactors don't run for that value, and the original
value is never recorded. The `REDACTION_FAILED_PLACEHOLDER` constant exported
from `@zuplo/runtime` holds this text.

Each failure writes a warning to your gateway's logs, at most once a minute for
each kind of record:

```txt
A redactor registered with runtime.addTelemetryRedactor threw or did not return a string while redacting a log record. The value was recorded as [REDACTION_FAILED]. Further failures are reported at most once a minute.
```

The warning never contains the value or the redactor's error message.

## Limits

| Limit              | Value                                                                                                          |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| Redactors          | 16, registered only in `runtimeInit`                                                                           |
| Value length       | 32,768 characters. Longer values, and longer redactor output, are cut to that length and marked `[Truncated]`. |
| Values per record  | 10,000, shared by every field of the record. Values past the limit are recorded as `[Truncated]`.              |
| Nesting depth      | 20 levels. Deeper values are recorded as `[Truncated]`.                                                        |
| Values that repeat | A value that contains itself is recorded as `[Circular]` where it repeats.                                     |

## What's redacted

When a redactor is registered, these values pass through it before any log
transport, tracing exporter, plugin, or Zuplo service receives the record. Every
destination receives the same redacted record. With no redactor registered,
records are unchanged.

| `kind`        | Record                                                                                                                                                                                             | Values passed to the redactor                                                                                                        |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `log`         | [Logs](./logger.mdx) from Zuplo and from your code                                                                                                                                                 | Every message argument, log properties, custom log fields, and the request URL given to log transports                               |
| `span`        | [OpenTelemetry](../articles/opentelemetry.mdx) spans                                                                                                                                               | The span name, every attribute, every event name and event attribute, every link attribute, and the status message                   |
| `analytics`   | [Analytics](../analytics/overview.md) request records and events                                                                                                                                   | The request URL (path and query string), properties your code sets with `setLogProperties`, and the resource URI on analytics events |
| `request-log` | Request logger plugins: [Azure Blob](../articles/plugin-azure-blob.mdx), [Azure Event Hubs](../articles/plugin-azure-event-hubs.mdx), and [Hydrolix](../articles/plugin-hydrolix-traffic-peak.mdx) | Every value of each entry                                                                                                            |
| `audit-log`   | Events from the [Audit Logs policy](../policies/audit-log-inbound.mdx)                                                                                                                             | Every field except the fields listed in the next section                                                                             |

### Values that aren't redacted

Zuplo computes usage, analytics, and billing from some recorded values, and
generates others itself. These values are always recorded as measured, even when
a redactor is registered:

| Record                   | Values recorded as measured                                                                                                            |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| Analytics request record | Route pattern, method, status code, sizes, timings, user, client IP, geographic fields, ray ID, request IDs, and properties Zuplo sets |
| Analytics events         | Every field except the resource URI, including the model, provider, app, team, and consumer that AI Gateway usage is metered by        |
| Audit events             | `specversion`, `id`, `time`, `datacontenttype`, `bucketid`, `actortype`, `requestid`, `success`, `httpmethod`, and `httpstatus`        |

Keep sensitive data out of route paths and user identifiers, because a redactor
can't remove it from these fields.

### Data that redactors don't cover

Redactors apply only to records the gateway writes through the paths listed
earlier. They don't cover:

- Data that metering and analytics policies send to their services, such as
  Moesif, ReadMe, Amberflo, and OpenMeter. Those services receive the raw
  values.
- Request and response bodies sent to Akamai API Security.
- Output written directly to the console instead of through `context.log`, and
  uncaught errors captured by the hosting platform.
- Logs written before `runtimeInit` runs.
- OpenTelemetry resource attributes and other static configuration.
- Data the gateway stores to serve requests, such as cache entries and rate
  limit keys.
- Records after they leave the gateway.

## Related

- [Runtime extensions](./runtime-extensions.mdx)
- [Logger](./logger.mdx)
- [OpenTelemetry](../articles/opentelemetry.mdx)
- [Data Loss Prevention policy](../policies/ai-gateway-dlp-inbound.mdx)
