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
Open
anik120
wants to merge
1
commit into
lightspeed-core:main
Choose a base branch
from
anik120:in-process-pii-redaction
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+465
−27
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| """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_results", | ||
| "feedback.comment", | ||
| ) | ||
|
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() | ||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # In-process PII redaction e2e. | ||
| # | ||
| # The e2e stack runs with in-process redaction on by default: lightspeed-stack is | ||
| # built with the spaCy model (en_core_web_lg, baked into the image) and runs the | ||
| # RedactingSpanExporter (OTEL_TRACES_EXPORTER=redacting_otlp, set in docker-compose). | ||
| # Spans are redacted in-process before being exported over OTLP to the mock | ||
| # collector (mock-otel), which the test uses as a telemetry sink. This scenario | ||
| # asserts a known PII value sent in a query never reaches the collector, while a | ||
| # non-PII marker still arrives. | ||
| @cfg_authorized @OTel @OTel-redaction @skip-in-prow | ||
| Feature: In-process PII redaction of exported telemetry | ||
|
|
||
| Background: | ||
| Given The service is started locally | ||
| And The system is in default state | ||
| And An OpenTelemetry service is running and listening for OTLP data | ||
| And I set the Authorization header to Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ikpva | ||
| And REST API service prefix is /v1 | ||
| And the Lightspeed stack configuration directory is "tests/e2e/configuration" | ||
| And The service uses the lightspeed-stack-authorized.yaml configuration | ||
| And The service is restarted | ||
|
|
||
|
|
||
| Scenario: PII in a responses endpoint query is redacted in-process before telemetry is exported | ||
| Given The system is in default state | ||
| When I use "responses" to ask question with authorization header | ||
| """ | ||
| { | ||
| "input": "Reach me at sam@redhat.com regarding e2e-redaction-marker", | ||
| "model": "{PROVIDER}/{MODEL}", | ||
| "stream": false, | ||
| "safety_identifier": "e2e-redaction-marker" | ||
| } | ||
| """ | ||
| Then The status code of the response is 200 | ||
| And The OpenTelemetry service received data containing e2e-redaction-marker | ||
| And The OpenTelemetry service did not receive data containing sam@redhat.com | ||
|
|
||
|
|
||
| Scenario: PII in a query endpoint request is redacted in-process before telemetry is exported | ||
| Given The system is in default state | ||
| When I use "query" to ask question with authorization header | ||
| """ | ||
| { | ||
| "query": "My email is john.doe@redhat.com for e2e-query-redaction-marker" | ||
| } | ||
| """ | ||
| Then The status code of the response is 200 | ||
| And The OpenTelemetry service received data containing e2e-query-redaction-marker | ||
| And The OpenTelemetry service did not receive data containing john.doe@redhat.com | ||
|
|
||
|
|
||
| Scenario: PII in a streaming_query endpoint request is redacted in-process before telemetry is exported | ||
| Given The system is in default state | ||
| When I use "streaming_query" to ask question with authorization header | ||
| """ | ||
| { | ||
| "query": "Contact me at alice.smith@redhat.com for e2e-streaming-redaction-marker" | ||
| } | ||
| """ | ||
| Then The status code of the response is 200 | ||
| And The OpenTelemetry service received data containing e2e-streaming-redaction-marker | ||
| And The OpenTelemetry service did not receive data containing alice.smith@redhat.com |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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