Skip to content

Commit 31e66d4

Browse files
michelle-hadfield-navaCopilotyoomlamKevinBoyerAtNava
authored
feat: Add presidio PII filter for OTEL data logs (#14)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Yoom Lam <yoom@navapbc.com> Co-authored-by: Kevin Boyer <kevinboyer@navapbc.com>
1 parent 0a37bbb commit 31e66d4

7 files changed

Lines changed: 1158 additions & 58 deletions

File tree

app/docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ services:
3838
- PHOENIX_TLS_KEY_FILE_PASSWORD=
3939
# For verifying client certificates
4040
- PHOENIX_TLS_VERIFY_CLIENT=False
41+
# In case we want to disable PII Redaction
42+
- REDACT_PII=${REDACT_PII}
4143

4244
# See README.md to enable authentication locally
4345
# - PHOENIX_ENABLE_AUTH=True

app/poetry.lock

Lines changed: 919 additions & 54 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ arize-phoenix-client = "^1.15.3"
2929
arize-phoenix-otel = "^0.13.0"
3030
amazon-bedrock-haystack = "^3.10.0"
3131
certifi = "^2025.8.3"
32+
presidio-analyzer = "^2.2.359"
33+
presidio-anonymizer = "^2.2.359"
34+
spacy = "^3.8.7"
35+
en-core-web-lg = {url = "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"}
3236

3337
[tool.poetry.group.dev.dependencies]
3438
certifi = "^2025.8.3"
@@ -45,7 +49,7 @@ pytest = "^7.4.2"
4549
pytest-watch = "^4.2.0"
4650
pytest-lazy-fixture = "^0.6.3"
4751
types-pyyaml = "^6.0.12.11"
48-
setuptools = "^70.0.0"
52+
setuptools = "^78.1.1"
4953
debugpy = "^1.8.1"
5054
ruff = "^0.4.9"
5155

app/src/app_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ class AppConfig(PydanticBaseEnvConfig):
1212
phoenix_collector_endpoint: str = "https://phoenix:6006"
1313
batch_otel: bool = True
1414

15+
redact_pii: bool = True
16+
1517

1618
config = AppConfig()

app/src/common/phoenix_utils.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import logging
2+
import os
23

34
import httpx
5+
import opentelemetry.exporter.otlp.proto.http.trace_exporter as otel_trace_exporter
46

57
# https://docs.arize.com/phoenix/tracing/integrations-tracing/haystack
68
# Arize's Phoenix observability platform
79
import phoenix.client
810
import phoenix.otel
11+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
912

1013
from src.app_config import config
14+
from src.logging.presidio_pii_filter import PresidioRedactionSpanProcessor
1115

1216
logger = logging.getLogger(__name__)
1317

@@ -48,9 +52,21 @@ def configure_phoenix(only_if_alive: bool = True) -> None:
4852
logger.info("Using phoenix.otel.register with batch_otel=%s", config.batch_otel)
4953
# This uses PHOENIX_COLLECTOR_ENDPOINT and PHOENIX_PROJECT_NAME env variables
5054
# and PHOENIX_API_KEY to handle authentication to Phoenix.
51-
phoenix.otel.register(
55+
tracer_provider = phoenix.otel.register(
5256
endpoint=trace_endpoint,
5357
batch=config.batch_otel,
5458
# Auto-instrument based on installed OpenInference dependencies
5559
auto_instrument=True,
5660
)
61+
62+
if config.redact_pii:
63+
phoenix_api_key = os.environ.get("PHOENIX_API_KEY")
64+
span_exporter = otel_trace_exporter.OTLPSpanExporter(
65+
endpoint=trace_endpoint, headers={"Authorization": f"Bearer {phoenix_api_key}"}
66+
)
67+
# Create the PII redacting processor with the OTLP exporter
68+
pii_processor = PresidioRedactionSpanProcessor(span_exporter)
69+
# Add the pii processor to the otel instance
70+
if config.batch_otel:
71+
tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
72+
tracer_provider.add_span_processor(pii_processor)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import json
2+
import logging
3+
from typing import Any, Dict, List, Optional
4+
5+
from opentelemetry.sdk.trace import Event, ReadableSpan, Span, SpanProcessor
6+
from opentelemetry.sdk.trace.export import SpanExporter
7+
from presidio_analyzer import AnalyzerEngine, recognizer_result
8+
from presidio_analyzer.nlp_engine import NlpEngineProvider
9+
from presidio_anonymizer import AnonymizerEngine
10+
from presidio_anonymizer.entities import OperatorConfig, RecognizerResult
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class PresidioRedactionSpanProcessor(SpanProcessor):
16+
"""
17+
OpenTelemetry span processor that redacts PII data using Microsoft Presidio.
18+
"""
19+
20+
def __init__(
21+
self,
22+
exporter: SpanExporter,
23+
entities: Optional[List[str]] = None,
24+
language: str = "en",
25+
):
26+
"""
27+
Initialize the PII redacting processor with Presidio and an exporter.
28+
29+
Args:
30+
exporter: The span exporter to use after PII redaction
31+
entities: List of PII entity types to detect and redact.
32+
If None, uses a default set of common PII types.
33+
language: Language to use for NLP analysis
34+
"""
35+
self._exporter = exporter
36+
37+
# Default supported entity types in Presidio
38+
self._default_entities = [
39+
"PERSON",
40+
"EMAIL_ADDRESS",
41+
"PHONE_NUMBER",
42+
"US_SSN",
43+
"CREDIT_CARD",
44+
"IP_ADDRESS",
45+
"DATE_TIME",
46+
"US_BANK_NUMBER",
47+
"US_DRIVER_LICENSE",
48+
"LOCATION",
49+
"NRP",
50+
"US_PASSPORT",
51+
"US_ITIN",
52+
"CRYPTO",
53+
"UK_NHS",
54+
"IBAN_CODE",
55+
]
56+
57+
self._entities = entities or self._default_entities
58+
59+
# Set up Presidio engines with proper configuration
60+
nlp_configuration = {
61+
"nlp_engine_name": "spacy",
62+
"models": [{"lang_code": language, "model_name": "en_core_web_lg"}],
63+
}
64+
nlp_engine = NlpEngineProvider(nlp_configuration=nlp_configuration).create_engine()
65+
self._analyzer = AnalyzerEngine(nlp_engine=nlp_engine)
66+
self._anonymizer = AnonymizerEngine()
67+
68+
# Default operator for anonymization (replacement with entity type)
69+
self._operators = {
70+
entity: OperatorConfig("replace", {"new_value": f"[REDACTED_{entity}]"})
71+
for entity in self._entities
72+
}
73+
74+
def _redact_string(self, value: str) -> str:
75+
"""Redact PII from any string value using Presidio."""
76+
if not value.strip():
77+
return value
78+
79+
try:
80+
# Analyze the text for PII
81+
fields_for_redaction: list[recognizer_result.RecognizerResult] = self._analyzer.analyze(
82+
text=value, entities=self._entities, language="en"
83+
)
84+
85+
# Converting analyzer recognizer result into the anonymizer entity
86+
# https://github.com/microsoft/presidio/issues/1396
87+
anonymizer_redaction_list = [
88+
RecognizerResult(
89+
entity_type=field_for_redaction.entity_type,
90+
start=field_for_redaction.start,
91+
end=field_for_redaction.end,
92+
score=field_for_redaction.score,
93+
)
94+
for field_for_redaction in fields_for_redaction
95+
]
96+
97+
# If PII is found, anonymize it
98+
if anonymizer_redaction_list:
99+
anonymized_text = self._anonymizer.anonymize(
100+
text=value,
101+
analyzer_results=anonymizer_redaction_list,
102+
operators=self._operators,
103+
)
104+
return anonymized_text.text
105+
106+
return value
107+
except Exception as e:
108+
logger.error(f"Error redacting string: {str(e)}")
109+
return "[REDACTION_ERROR]"
110+
111+
def _redact_value(self, value: Any) -> Any:
112+
"""
113+
Redact PII from any value type.
114+
Handles strings, numbers, booleans, lists, and dictionaries.
115+
"""
116+
if isinstance(value, str):
117+
try:
118+
# Try to parse as JSON first
119+
json_obj = json.loads(value)
120+
return json.dumps(self._redact_value(json_obj))
121+
except json.JSONDecodeError:
122+
# If not valid JSON, treat as regular string
123+
return self._redact_string(value)
124+
elif isinstance(value, dict):
125+
return {k: self._redact_value(v) for k, v in value.items()}
126+
elif isinstance(value, list):
127+
return [self._redact_value(item) for item in value]
128+
elif isinstance(value, (int, float, bool, type(None))):
129+
return value
130+
else:
131+
# Convert any other types to string and redact
132+
return self._redact_string(str(value))
133+
134+
def _redact_span_attributes(self, span: ReadableSpan) -> Dict[str, Any]:
135+
"""
136+
Create a new dictionary of redacted span attributes.
137+
"""
138+
redacted_attributes = {}
139+
140+
for key, value in span.attributes.items(): # type: ignore[union-attr]
141+
# Skip certain metadata attributes that shouldn't contain PII
142+
if key in {"service.name", "telemetry.sdk.name", "telemetry.sdk.version"}:
143+
redacted_attributes[key] = value
144+
continue
145+
146+
try:
147+
redacted_value = self._redact_value(value)
148+
redacted_attributes[key] = redacted_value
149+
except Exception as e:
150+
redacted_attributes[key] = "[REDACTION_ERROR]"
151+
logger.error(f"Error redacting attribute {key}: {str(e)}")
152+
153+
return redacted_attributes
154+
155+
def _create_redacted_span(self, span: ReadableSpan) -> ReadableSpan:
156+
"""
157+
Create a new span with redacted attributes instead of modifying the original.
158+
"""
159+
# Create redacted attributes
160+
redacted_attributes = self._redact_span_attributes(span)
161+
162+
# Redact span name
163+
redacted_name = self._redact_string(span.name)
164+
165+
# Handle events
166+
redacted_events = []
167+
for event in span.events:
168+
redacted_event_attrs = {k: self._redact_value(v) for k, v in event.attributes.items()} # type: ignore[union-attr]
169+
# Create new event with redacted attributes
170+
redacted_event = Event(
171+
name=self._redact_string(event.name),
172+
attributes=redacted_event_attrs,
173+
timestamp=event.timestamp,
174+
)
175+
redacted_events.append(redacted_event)
176+
177+
# Create new span with redacted data
178+
redacted_span = ReadableSpan(
179+
name=redacted_name,
180+
context=span.get_span_context(),
181+
parent=span.parent,
182+
resource=span.resource,
183+
attributes=redacted_attributes,
184+
events=redacted_events,
185+
links=span.links,
186+
kind=span.kind,
187+
status=span.status,
188+
start_time=span.start_time,
189+
end_time=span.end_time,
190+
instrumentation_scope=span.instrumentation_scope,
191+
)
192+
193+
return redacted_span
194+
195+
def on_start(self, span: Span, parent_context: Optional[Any] = None) -> None:
196+
"""Called when a span starts."""
197+
pass
198+
199+
def on_end(self, span: ReadableSpan) -> None:
200+
"""Called when a span ends. Creates a redacted copy and exports it."""
201+
redacted_span = self._create_redacted_span(span)
202+
self._exporter.export([redacted_span])
203+
204+
def shutdown(self) -> None:
205+
"""Shuts down the processor and exporter."""
206+
self._exporter.shutdown()
207+
208+
def force_flush(self, timeout_millis: int = 30000) -> bool:
209+
"""Forces flush of pending spans."""
210+
self._exporter.force_flush(timeout_millis)
211+
return True

infra/app/app-config/dev.tf

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ module "dev_config" {
1818
extra_identity_provider_callback_urls = ["http://localhost"]
1919
extra_identity_provider_logout_urls = ["http://localhost"]
2020

21-
service_cpu = 512
22-
service_memory = 2048
21+
service_cpu = 1024
22+
service_memory = 8192
2323

2424
# Enables ECS Exec access for debugging or jump access.
2525
# See https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-exec.html

0 commit comments

Comments
 (0)