Privacy & data handling¶
Undercurrent runs inside your inference process and sees the same data your model does. This page lists what each component touches, what leaves the process and how to control it. As the operator, you decide what gets recorded, where it goes and how long it is kept.
No telemetry¶
Undercurrent does not phone home. It collects no usage statistics, crash reports or analytics, and it makes no network calls of its own except to endpoints you configure.
We checked this by searching the package source (src/undercurrent/) for
network clients (urllib, requests, httpx, aiohttp, http.client,
socket, gRPC, huggingface_hub) and for telemetry or analytics libraries.
There are exactly two ways the package reaches the network:
| What | When | Where it goes |
|---|---|---|
WebhookLogSink |
Only if you create one | The URL you pass it (an HTTP POST with a JSON body) |
| Model and tokenizer downloads | When you load a model by Hub ID | The Hugging Face Hub, through Transformers' from_pretrained (HF adapter) or through vLLM's own model loader (vLLM adapter) |
Model downloads are made by Transformers and vLLM, not by Undercurrent, and
they follow those libraries' settings. To stay fully offline, pass a local
model path, or set HF_HUB_OFFLINE=1 once the model is cached. vLLM has its
own optional usage-statistics reporting, which is separate from Undercurrent.
See the vLLM documentation for how to turn it off (for example
VLLM_NO_USAGE_STATS=1).
The OpenAI-compatible server example listens for HTTP requests, but it is example code and isn't installed with the package.
What each component touches¶
| Component | Data it handles | Where it lives |
|---|---|---|
Adapters (undercurrent.adapters.hf, .vllm) |
Activations at the layers and positions your spec selects. The prompt string, model name and prompt length, passed to probes as RequestContext.prompt_metadata ({"model", "prompt", "prompt_len"}). |
In memory. Captured activations are copied to the CPU and handed to the router. With vLLM, when the engine runs in a separate process, activations and abort decisions cross between processes through vLLM's own RPC channel. |
Router (undercurrent.router) |
ActivationRecords (one activation tensor plus request ID, extraction-point name, layer and token position), and probe signals and results |
In memory, for the lifetime of the request |
| Probes (your code) | Anything above. A probe can read the raw prompt from prompt_metadata. |
Whatever your probe does with it |
Metrics (Router.get_metrics, MetricsSink) |
Counts and timings per (request ID, extraction point): queue depth, drops, activation latency and probe errors. No activations or text. | In memory, and in any MetricsSink you attach |
Observation sinks (undercurrent.sinks) |
Probe signals and results, serialised as JSON records (see below) | A local file, or a webhook you configure |
Python logging |
Warnings about probe errors, dropped records and circuit-breaker trips. These include request IDs and extraction-point names, and the exceptions (messages and tracebacks) your probes raise. | Wherever you route Python logging |
Undercurrent never writes activation tensors to a sink. Tensors and raw bytes in probe output are reduced to a summary (type, shape, dtype, device or length) before they are logged. The library itself never writes prompt or generated text into a sink record. That only happens if a probe copies it into its verdict, metadata or exception messages.
What a sink record contains¶
Both built-in sinks write the same record shape:
{
"kind": "result",
"request_id": "req-42",
"extraction_point_name": "harm_score_l16",
"timestamp": 1790000000.0,
"payload": {
"request_id": "req-42",
"extraction_point_name": "harm_score_l16",
"verdict": "...probe-defined...",
"signal_history": ["...signal payloads..."],
"metadata": {"...": "probe-defined"}
}
}
For a "signal" record, payload holds action, confidence, timestamp
and a probe-defined metadata dict.
kindandtimestampare generated by the library.extraction_point_namecomes from your spec.request_idis whatever your serving code or the adapter uses. It is normally opaque, but don't put user identifiers in it.verdictandmetadataare defined by the probe and may contain anything the probe puts there. This is where personal data usually ends up.
What leaves the process, by sink¶
| Sink | Destination | Default redaction |
|---|---|---|
| None attached | Nothing is recorded by Undercurrent. Results are only returned to your own code. | — |
FileLogSink(path) |
NDJSON lines appended to a local file | None. The data stays on the machine, so records are written as they are. |
WebhookLogSink(url) |
JSON POST to url, from a background thread, with retries |
Prompt text is redacted. Any key named in DEFAULT_PROMPT_TEXT_KEYS (prompt, text, generated_text, completion, messages, input_ids, token_ids and others), at any depth, has its value replaced with "[REDACTED]". |
WebhookLogSink(..., dead_letter_path=...) |
Records that still fail after the retries go to a local NDJSON file | Records are redacted before the first send attempt, so the dead-letter file and error logs only see the redacted version. |
WebhookLogSink sends no authentication headers of its own and accepts any
URL scheme. Use an HTTPS endpoint you control. See
SECURITY.md.
Redacting and allowlisting fields¶
Every built-in sink takes a redact function: (record: dict) -> dict | None.
It runs on a copy of each record just before the record is written or sent.
Returning None drops the record. If the function raises an exception, the
record is dropped rather than sent unredacted, and the sink's
redaction_error_count goes up.
Redact or drop keys with the helpers. A plain key matches at any depth. A
dotted path such as metadata.raw_scores matches that chain of keys.
from undercurrent.sinks import FileLogSink, WebhookLogSink, chain, drop_keys, redact_keys
# Local file: redact a user ID your probes record, drop a bulky debug field.
file_sink = FileLogSink(
"observations.ndjson",
redact=chain(redact_keys("user_id"), drop_keys("metadata.raw_scores")),
)
# Webhook: the default prompt-text redaction runs first, then yours.
webhook = WebhookLogSink(
"https://observability.example.com/hook",
dead_letter_path="dead_letters.ndjson",
redact=redact_keys("error", "user_id"),
)
The router may log a probe's exception message under metadata["error"].
Redact error if your probes might include input text in their exceptions.
To keep data to a minimum, use an allowlist instead: send only the fields you have decided to share, and drop everything else.
from undercurrent.sinks import WebhookLogSink
ALLOWED_PAYLOAD_KEYS = {"action", "confidence", "verdict"}
def allowlist(record):
if record["kind"] != "result":
return None # only send final results, never intermediate signals
return {
"kind": record["kind"],
"request_id": record["request_id"],
"extraction_point_name": record["extraction_point_name"],
"timestamp": record["timestamp"],
"payload": {k: v for k, v in record["payload"].items() if k in ALLOWED_PAYLOAD_KEYS},
}
webhook = WebhookLogSink("https://observability.example.com/hook", redact=allowlist)
verdict is defined by the probe. Only allowlist it if you know your probes
put a label or score there, not text.
Sending prompt text to a webhook is opt-in:
WebhookLogSink(url, include_prompt_text=True). Only do this if the
receiving system is cleared to hold that data.
Retention¶
Undercurrent doesn't rotate, expire or delete anything it writes. Retention of sink files, dead-letter files, webhook destinations, metrics backends and application logs is the operator's responsibility. Set a retention period that fits why you collect the data, enforce it with your log rotation or storage policy, and restrict access to the files (they may hold data derived from user prompts).
GDPR and PII checklist for operators¶
Not legal advice
This checklist is general guidance to help you think through data protection when you deploy Undercurrent. It is not legal advice and doesn't guarantee compliance with the GDPR or any other law. Ask your own legal counsel or data protection officer about your deployment.
- [ ] Map the data. List which probes run, what each one puts in its verdict and metadata, and which sinks and metrics backends receive it.
- [ ] Identify personal data. Prompts, generated text, request IDs tied to user accounts and probe verdicts about a person's content can all be personal data. Some probes may produce special-category data, such as inferences about health or beliefs.
- [ ] Have a lawful basis and a stated purpose for monitoring and for each kind of record you keep. Don't reuse the data for unrelated purposes.
- [ ] Minimise. Prefer allowlists to blocklists. Log scores and labels, not text. Attach sinks only where you need the records.
- [ ] Be transparent. Tell users in your privacy notice that model activity is monitored and what is logged.
- [ ] Assess automated decisions. If an inline probe blocks or aborts responses in a way that significantly affects people, check whether rules on automated decision-making apply, and offer a way to contest it.
- [ ] Run a DPIA where required, for example for large-scale monitoring or sensitive inferences.
- [ ] Secure transfers and storage. Use HTTPS for webhooks, restrict access to sink and dead-letter files, and check where the webhook receiver stores the data, including international transfers.
- [ ] Set retention periods and delete data on schedule (see Retention).
- [ ] Support data-subject rights. Make sure you can find and delete or export one person's records if asked, for example by request ID.
- [ ] Sign processor agreements with any third party that receives sink data, such as a hosted logging or observability service.
See also Intended use & limitations and Observation sinks.