Repository navigation
LCORE-4313: in-process PII redaction #2990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| """Span exporter that redacts PII from span content before delegating to OTLP. | ||
|
|
||
| In-process (producer-side) sanitization: redaction happens inside lightspeed-core, | ||
| before spans leave the process, so no unredacted span is ever put on the wire. | ||
|
|
||
| It is wired via the ``opentelemetry_traces_exporter`` entry point, selected with | ||
| ``OTEL_TRACES_EXPORTER=redacting_otlp``. Auto-instrumentation | ||
| (``opentelemetry-instrument``) instantiates it and wraps it in the | ||
| ``BatchSpanProcessor``, so ``export()`` runs on the processor's background thread, | ||
| off the request path. The request/handler code is unchanged. | ||
|
|
||
| Redaction is delegated to the vendored ``pii_redaction_engine`` (Presidio + spaCy). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from collections.abc import Sequence | ||
|
|
||
| from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( | ||
| OTLPSpanExporter as GRPCSpanExporter, | ||
| ) | ||
| from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( | ||
| OTLPSpanExporter as HTTPSpanExporter, | ||
| ) | ||
| from opentelemetry.sdk.trace import ReadableSpan | ||
| from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult | ||
|
|
||
| from log import get_logger | ||
| from pii_redaction_engine.anonymizer import PIIAnonymizer | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
| # Span attributes whose string values may carry PII (see utils.otel_tracing). | ||
| # Note: request.safety_identifier is intentionally excluded (it is a caller-supplied | ||
| # identifier for tracking, not user content). | ||
| CONTENT_ATTRIBUTES: tuple[str, ...] = ( | ||
| "request.input", | ||
| "response.output", | ||
| "rag.input", | ||
| "tool_calls", | ||
| "tool_results", | ||
| "feedback.comment", | ||
|
Comment on lines
+40
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redact
|
||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| def _default_otlp_exporter() -> SpanExporter: | ||
| """Return an OTLP span exporter matching the configured protocol. | ||
|
|
||
| Matches the stock SDK's supported values exactly: ``grpc`` (default) or | ||
| ``http/protobuf``. Both read the standard ``OTEL_EXPORTER_OTLP_*`` environment | ||
| for endpoint, headers, and TLS. Any other value (e.g. ``http/json``, which | ||
| neither OTLP span exporter supports) is rejected rather than silently sending | ||
| protobuf. | ||
|
|
||
| Returns: | ||
| A gRPC or HTTP OTLP span exporter. | ||
|
|
||
| Raises: | ||
| ValueError: if the configured protocol is not ``grpc`` or ``http/protobuf``. | ||
|
|
||
| """ | ||
| protocol = ( | ||
| ( | ||
| os.getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") | ||
| or os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL") | ||
| or "grpc" | ||
| ) | ||
| .strip() | ||
| .lower() | ||
| ) | ||
| if protocol == "grpc": | ||
| return GRPCSpanExporter() | ||
| if protocol == "http/protobuf": | ||
| return HTTPSpanExporter() | ||
| raise ValueError( | ||
| f"unsupported OTEL_EXPORTER_OTLP_PROTOCOL {protocol!r}; " | ||
| "supported values are 'grpc' and 'http/protobuf'" | ||
| ) | ||
|
|
||
|
|
||
| class RedactingSpanExporter(SpanExporter): | ||
| """Wraps an OTLP span exporter, redacting content attributes before export. | ||
|
|
||
| Attributes: | ||
| delegate: The downstream exporter that actually ships the (redacted) spans. | ||
|
|
||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| delegate: SpanExporter | None = None, | ||
| anonymizer: PIIAnonymizer | None = None, | ||
| ) -> None: | ||
| """Initialize the exporter. | ||
|
|
||
| Args: | ||
| delegate: Downstream exporter. Defaults to an OTLP exporter matching | ||
| ``OTEL_EXPORTER_OTLP_PROTOCOL`` (gRPC or HTTP), which reads the | ||
| standard ``OTEL_EXPORTER_OTLP_*`` environment. | ||
| anonymizer: Redaction engine. Defaults to a ``PIIAnonymizer``, which | ||
| loads the spaCy model once at construction (off the request path). | ||
| Injectable for testing. | ||
|
|
||
| """ | ||
| self._delegate = delegate or _default_otlp_exporter() | ||
| self._anonymizer = anonymizer or PIIAnonymizer() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| logger.info( | ||
| "RedactingSpanExporter active; content attributes redacted in-process" | ||
| ) | ||
|
|
||
| def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: | ||
| """Redact content attributes, then delegate the export. | ||
|
|
||
| Fail closed: if redaction of a span raises, that span is dropped rather | ||
| than exported unredacted, so a redaction failure can never leak PII. Other | ||
| spans in the batch are still exported. | ||
|
|
||
| Args: | ||
| spans: The spans to redact and export. | ||
|
|
||
| Returns: | ||
| The delegate's export result, or ``SUCCESS`` if every span was dropped | ||
| (reporting failure would only make the batch processor retry the same | ||
| failing spans). | ||
|
|
||
| """ | ||
| safe_spans: list[ReadableSpan] = [] | ||
| for span in spans: | ||
| try: | ||
| self._redact_span(span) | ||
| except Exception: # pylint: disable=broad-except # noqa: BLE001 | ||
| # Log the span name only, never the content being redacted. | ||
| logger.error( | ||
| "in-process redaction failed for span %r; dropping it to avoid " | ||
| "exporting unredacted PII", | ||
| span.name, | ||
| ) | ||
| continue | ||
| safe_spans.append(span) | ||
|
|
||
| if not safe_spans: | ||
| return SpanExportResult.SUCCESS | ||
| return self._delegate.export(safe_spans) | ||
|
|
||
| def _redact_span(self, span: ReadableSpan) -> None: | ||
| """Rewrite the content attributes of a span in place with redacted text. | ||
|
|
||
| Args: | ||
| span: The span whose content attributes are redacted in place. | ||
|
|
||
| Raises: | ||
| Exception: if the redaction engine fails. The caller drops the span | ||
| (fail closed) rather than exporting unredacted content. | ||
|
|
||
| """ | ||
| attributes = span.attributes | ||
| if not attributes: | ||
| return | ||
|
|
||
| new_attributes = dict(attributes) | ||
| changed = False | ||
| for key in CONTENT_ATTRIBUTES: | ||
| value = new_attributes.get(key) | ||
| if not isinstance(value, str) or not value: | ||
| continue | ||
| redacted = self._anonymizer.anonymize_text(value) | ||
| if redacted != value: | ||
| new_attributes[key] = redacted | ||
| changed = True | ||
|
|
||
| if changed: | ||
| # ReadableSpan exposes attributes via self._attributes; replacing it | ||
| # with a plain dict is what the OTLP encoder reads at export time. | ||
| span._attributes = new_attributes # pylint: disable=protected-access | ||
|
|
||
| def shutdown(self) -> None: | ||
| """Shut down the delegate exporter.""" | ||
| self._delegate.shutdown() | ||
|
|
||
| def force_flush(self, timeout_millis: int = 30_000) -> bool: | ||
| """Force-flush the delegate exporter.""" | ||
| return self._delegate.force_flush(timeout_millis) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,3 +25,33 @@ Feature: OpenTelemetry observability tests | |
| """ | ||
| Then The status code of the response is 200 | ||
| And The OpenTelemetry service received data containing e2e-otel-delivery-marker | ||
|
|
||
|
|
||
| Scenario: PII in span content is redacted in-process before telemetry export | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The e2e scenario is a good end-to-end smoke (real redaction + real export). But I think we also need to add some integration tests. Integration tests can be a separate PR, but wanted to make sure I mention it here. |
||
| # One request carrying every default-redacted entity type. safety_identifier is | ||
| # intentionally not redacted (it is a caller-supplied tracking id, not user | ||
| # content), so it doubles as the delivery marker. | ||
| Given The system is in default state | ||
| When I use "responses" to ask question with authorization header | ||
| """ | ||
| { | ||
| "input": "Reach me at test.user@redhat.com or 212-555-0199, host db.example.com at 10.1.2.3; contact Barack Obama in Chicago regarding e2e-redaction-check", | ||
| "model": "{PROVIDER}/{MODEL}", | ||
| "stream": false, | ||
| "safety_identifier": "e2e-redaction-check" | ||
| } | ||
| """ | ||
| Then The status code of the response is 200 | ||
| And The OpenTelemetry service received data containing e2e-redaction-check | ||
| # Raw PII values must never reach the collector. | ||
| And The OpenTelemetry service did not receive data containing test.user@redhat.com | ||
| And The OpenTelemetry service did not receive data containing 212-555-0199 | ||
| And The OpenTelemetry service did not receive data containing db.example.com | ||
| And The OpenTelemetry service did not receive data containing 10.1.2.3 | ||
| # Each entity is replaced in-process with its typed marker. | ||
| And The OpenTelemetry service received data containing <EMAIL_ADDRESS> | ||
| And The OpenTelemetry service received data containing <PHONE_NUMBER> | ||
| And The OpenTelemetry service received data containing <HOSTNAME> | ||
| And The OpenTelemetry service received data containing <IP_ADDRESS> | ||
| And The OpenTelemetry service received data containing <PERSON> | ||
| And The OpenTelemetry service received data containing <LOCATION> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 7018
Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 18910
Web query:
uv 0.8.15uv run --withtemporary environment sys.executable pip install spaCy CLI download model implementationResult:
Web query:
uv 0.8.15 --with layered ephemeral environment sys.executable PYTHONPATH pip install target project virtual environmentResult:
Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 41336
Web query:
spaCy 3.8.13 cli download source sys.executable -m pip install en_core_web_lgResult:
Install the spaCy model in the hermetic build path.
The non-hermetic command runs the project interpreter.
--with pipadds a temporary layer, but spaCy installs the model throughsys.executable, soen_core_web_lgremains in/app-root/.venv.The hermetic branch installs
spacybut noten_core_web_lg.redacting_otlpthen constructsPIIAnonymizer, which raisesValueErrorwhen the model is absent. Add a pinned model wheel to the hermetic dependency set and install it into/app-root/.venv.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@tisnik @jameswnl highlighting this: in non-hermitic builds, the model will be downloaded during image build via
python -m spacy download en_core_web_lg.For hermetic builds, the model needs to be available in the Konflux index