Skip to content

Commit 7157cba

Browse files
committed
LCORE-XXXX: (design) Raw eval content on core otel spans and PII redaction strategy
Signed-off-by: Anik Bhattacharjee <anbhatta@redhat.com>
1 parent 5208801 commit 7157cba

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

‎docs/design/observability-opentelemetry/observability-opentelemetry-design.md‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,183 @@ No **required** change to JSON requests/responses. The `/config` response gains
374374
- Which `OTEL_*` variables are included in the `/config` scrape?
375375

376376

377+
## Addendum: Raw eval content on core spans and PII redaction strategy
378+
379+
| | |
380+
|--------------------------|-----------------------------------------------------------------------------------|
381+
| **Date** | 2026-09-22 |
382+
| **Authors** | Anik Bhattacharjee |
383+
| **Feature / Initiative** | [UIESTRAT-229: Enable collection & upload of Observability OTel data stripped of PII/sensitive data](https://redhat.atlassian.net/browse/UIESTRAT-229) |
384+
385+
This addendum records a deliberate change to the original "metadata only" data-handling
386+
stance for a **narrow, named set of spans**, the follow-on span-enrichment work it implies,
387+
and the PII-handling strategy that must accompany it. It supersedes the relevant parts of
388+
**R7** and the "Safe observability by design" bullet (§Why) **for the three core inference
389+
spans only** (`/v1/query`, `/v1/streaming_query`, `/v1/responses`). All other spans continue
390+
to follow the original metadata-only rule.
391+
392+
### A1. What changed and why
393+
394+
The original design captured **structured metadata only** — IDs, counts, lengths, coarse
395+
results — and explicitly avoided raw prompts and retrieved content (R7; §Why bullet 4;
396+
§Failure handling → "Metadata only... No raw prompts or retrieved content"). Content-bearing
397+
fields that were emitted at all (`request.input`, `response.output`, `feedback.comment`, …)
398+
were passed through `anonymize_value()` — an HMAC-SHA-256 digest (first 64 bits) plus a length
399+
tag, e.g. `[hash:ab12…:long:len=412]`.
400+
401+
[LCORE-3755](https://redhat.atlassian.net/browse/LCORE-3755) introduces a conflicting,
402+
legitimate requirement: an ML engineer building a **RAGAS / DeepEval** evaluation harness needs
403+
the **raw, un-hashed** input/response pairs — plus RAG chunk and tool-use detail — from the
404+
core inference spans in order to compute metrics such as faithfulness, context precision/recall,
405+
and tool-call correctness. A hash carries none of that signal: you cannot score a digest.
406+
407+
This addendum therefore:
408+
409+
1. **Removes `anonymize_value()` from content fields** on the covered endpoints (input, output,
410+
RAG input, feedback comment, and the A2A request id), while **keeping** it for pure identity
411+
fields (`user.id`). `safety_identifier` continues to be recorded verbatim — it is OpenAI's
412+
documented opaque, non-PII caller identifier.
413+
2. Establishes the **richer eval attributes** LCORE-3755 asks for as planned follow-on work
414+
(§A3).
415+
3. Establishes a **replacement PII strategy** — detect-and-redact via the [shared Presidio library](https://docs.google.com/document/d/1BNBIDUz-lLmUjT9N9cEMqgaQv9EqfLKwZrA8gtbtlY8/edit?tab=t.0#heading=h.9qfphopokr7u) being currently used by some lightspeed teams (eg Ask RedHat)
416+
### A2. LCORE-3755 gap analysis and remaining work
417+
418+
[PR#2731](https://github.com/lightspeed-core/lightspeed-stack/pull/2731) satisfies the raw request/response criteria. The remaining
419+
criteria are net-new instrumentation, tracked as follow-on work under LCORE-3755.
420+
421+
| # | LCORE-3755 acceptance criterion | Status | Notes |
422+
|---|---|---|---|
423+
| 1 | `conversation_id` on all 3 core spans | Present | emitted today as `session.id` — confirm naming vs. `conversation_id` |
424+
| 2 | `request` raw / un-hashed on all 3 | **Done (PR#2731)** | `request.input` now raw |
425+
| 3 | `response` raw / un-hashed on all 3 | **Done (slice 1)** | `response.output` now raw (incl. streaming path) |
426+
| 10 | `input_tokens` / `output_tokens` on all 3 | Present | already set on query, streaming, responses |
427+
| 7 | `model` name on all 3 | Partial | present on responses + non-streaming query; **missing on the streaming root span** |
428+
| 8 | `inference_time` / `latency` on all 3 | Partial | only implicit **span duration** today; decide whether an explicit attribute is required |
429+
| 11 | Shield **decision + reason** on all 3 | Partial | `shield.result` = `passed`/`blocked` on the child `shield.moderate` span; **no reason**, not on the root spans |
430+
| 4 | `rag_chunks` full list (content, source, score, attributes), inline + tool-based | **Net-new** | today only `rag.sources.count` + `rag.sources` = **doc URLs**; no content/score/attributes; nothing for `file_search_call` chunks |
431+
| 5 | `tool_calls` full detail (id, name, args) | **Net-new** | today only `tool.calls.count` / `tool.calls.names` |
432+
| 6 | `tool_results` full detail (id, status, content, round) | **Net-new (emission only)** | `id`/`status`/`content`/`round` already exist on `ToolResultSummary`; only the span emission is missing |
433+
| 12 | `round` on tool results, both paths | **Net-new (emission only)** | already a field on `ToolResultSummary`; net-new is emitting it (and keeping the streaming vs non-streaming paths in agreement) |
434+
| 13 | Unit tests for each of the above | **Net-new** | |
435+
436+
**Design considerations for the net-new work:**
437+
438+
#### This is additive raw content.
439+
440+
Every net-new field above (chunk content, tool args, tool results) is *additional* PII surface beyond
441+
input/output — it depends on §A4 being in place.
442+
443+
#### Attribute encoding for structured values
444+
445+
OpenTelemetry allows an attribute value to be only a primitive (`str`, `bool`, `int`, `float`)
446+
or a **homogeneous** sequence of primitives — no maps, no lists of objects. And `span.set_attribute()`
447+
does **not** raise on a bad type: the SDK logs a warning once and **silently drops the attribute**.
448+
So handing it a list of chunk objects yields a span with no data and no error, unnoticed until the
449+
eval harness comes up empty.
450+
451+
That rules out setting the structured fields (`rag_chunks`, `tool_calls`, `tool_results`) as native attributes.
452+
453+
**Approach — scalars native, collections as one JSON string each.** We will start with the most
454+
straightforward encoding that fits the above mentioned constraint: emit the scalar fields as native attributes,
455+
and serialize each collection to a single JSON-string attribute (`json.dumps` of the list).
456+
457+
| Field | Encoding | Type |
458+
|---|---|---|
459+
| `llm.latency_ms` | native attribute | `float` (unit encoded in the key) |
460+
| `shield.reason` | native attribute | `str` |
461+
| `llm.model.id` (streaming gap) | native attribute | `str` |
462+
| `rag.chunks` | one JSON-string attribute (`json.dumps` of the chunk list) | `str` |
463+
| `tool.calls` | one JSON-string attribute (`json.dumps` of the call list) | `str` |
464+
| `tool.results` | one JSON-string attribute (`json.dumps` of the result list) | `str` |
465+
466+
Notes for the implementer:
467+
468+
- **This is the encoding the eval backend reads.** Langfuse ingests span *attributes* (the
469+
observation path) but not generic span *events* — it maps only a specific set of GenAI event
470+
names (see [langfuse#11536](https://github.com/langfuse/langfuse/issues/11536)). So a per-item
471+
`add_event` approach would silently not show up for eval; JSON-string attributes are the safe fit.
472+
- **`json.dumps` needs `allow_nan=False` / `default=str`** so a `NaN`/`Infinity` score can't emit
473+
invalid JSON.
474+
- **Redact before serializing.** The §A4 redaction pass runs on the plain leaf strings
475+
(`content`/`args`/`source`) *before* `json.dumps` — never parse-redact-reserialize a finished
476+
JSON blob.
477+
478+
The one trade-off is that a JSON blob can't be field-filtered/aggregated by the backend. If that's
479+
ever needed, a field can be promoted to its own native scalar attribute later; this doesn't need to
480+
be solved up front.
481+
482+
483+
### A4. PII redaction strategy
484+
485+
**The problem.** Once spans carry raw text (prompts, responses, RAG chunks, tool I/O), that text
486+
can contain PII. So it must be scrubbed of PII before the span leaves LCORE — before OTLP export
487+
to a hosted backend such as LangFuse, and before any cross-org sharing. (This is a portfolio-wide
488+
obligation from Red Hat's AI Assessment (AIA/PIA) process, not specific to LCORE.) We do this by
489+
**detecting and redacting PII**.
490+
491+
**The redactor we want.** A shared, production-proven redactor already exists:
492+
`data-anonymizer` (used by Ask Red Hat / IFD-1767, Case Summarization, and KCS Drafting). It is
493+
Presidio-based and detects email, hostname, IP (v4/v6), location, organization, person, phone,
494+
and URL. Reusing it — rather than each team writing its own — is the goal.
495+
496+
**The catch — where it lives.** `data-anonymizer` is on Red Hat's **internal GitLab**
497+
(`gitlab.cee.redhat.com/uxe-data-ai-solutions/data-anonymizer`); it is not on PyPI. LCORE is
498+
built on **GitHub**, and a GitHub build/CI can't install a package from internal GitLab without
499+
internal credentials in the pipeline. So we **cannot simply add it to `pyproject.toml`.**
500+
501+
**The proposal.** Don't hard-wire any one redactor into LCORE. Define a small **redaction slot**
502+
— one interface, e.g. `redact(text) -> text` — that the telemetry path calls without knowing which
503+
redactor is behind it. Then:
504+
505+
- **GitHub build (default):** the slot is filled with public Presidio from PyPI. This gives the
506+
GitHub build a working, dependency-only telemetry redactor with no internal access required.
507+
- **Red Hat's internal build/deployment:** a downstream build of LCORE wires in the downstream
508+
`data-anonymizer` via an adapter, added at the stage where internal GitLab *is* reachable.
509+
510+
Net: LCORE always has a working telemetry redactor and still builds on GitHub, while Red Hat's
511+
deployment gets the shared portfolio library.
512+
513+
514+
**Where redaction runs: redact at emission, through one helper.** Content will be redacted
515+
as it is put on the span, not after. All content fields will go on spans through a **single
516+
centralized helper** — e.g. `set_content_attribute(span, key, text)` — that runs each value
517+
through the redaction slot (§A4) before calling `set_attribute`. For the JSON-string collections
518+
(§A2), the helper redacts each leaf string (`content`/`args`/`source`) **before** `json.dumps`, so
519+
the serialized attribute never contains raw PII. Properties of this approach:
520+
521+
- **The span never holds raw PII.** R12 ("redact before content leaves the process") is satisfied
522+
by construction — there is no in-memory window where a raw value sits on a span or in the export
523+
queue.
524+
- **One code path to audit.** Because content can only reach a span through the helper, there is a
525+
single place to review and test; individual emission sites cannot forget to redact (there is no
526+
raw path to `set_attribute` for content keys). A review/lint rule reinforces "content goes
527+
through the helper."
528+
- **Fail-closed.** If the redactor raises, the helper **omits** the field rather than emitting raw
529+
text — consistent with R9 (a tracing failure must never leak or break the request).
530+
- **Cost.** Redaction runs on the request path (synchronous), not in the export background thread.
531+
This is minor — a text pass over already-generated output, dwarfed by the LLM call — and only
532+
incurred when raw-content capture is enabled (R11 is opt-in/scoped). The helper is the single
533+
place to make it async/best-effort if it ever becomes a bottleneck.
534+
535+
536+
### A5. Requirements addendum
537+
538+
- **R11 — Raw eval content (scoped).** For `/v1/query`, `/v1/streaming_query`, and
539+
`/v1/responses`, spans shall emit raw, un-hashed request, response, RAG chunk, and tool-use
540+
detail sufficient for RAGAS / DeepEval evaluation. This scopes an exception to R7 for these
541+
spans only.
542+
- **R12 — PII redaction before export.** Raw content emitted under R11 shall pass through PII
543+
detection/redaction before leaving the process (OTLP export or cross-org sharing).
544+
- **R13 — Pluggable telemetry redaction, shared standard downstream.** LCORE shall expose a
545+
pluggable redaction interface for span content, with a default implementation (public Presidio
546+
from PyPI) that resolves in the GitHub build (no internal access). Red Hat's downstream build
547+
shall wire in the GitLab-hosted `data-anonymizer` via an adapter; the GitHub build shall **not**
548+
hard-depend on the internal package. This is separate from the existing `PiiRedactionCapability`
549+
shield, which is unaffected.
550+
- **R14 — Identity vs. content.** Cryptographic pseudonymization (`anonymize_value()`) is
551+
retained only for identity fields (`user.id`); it shall not be used on content fields.
552+
553+
377554
## Appendix A: Jira epics and related tracking
378555

379556
**Epics**

0 commit comments

Comments
 (0)