Ir al contenido
mcpd
sin daemon
Código

Ocultación y retención

Lo que nunca se almacena, y lo que deja de almacenarse. La ocultación corre antes de la escritura, porque un secreto borrado después ya se había escrito.

La ocultación corre antes del almacenamiento

Los prompts y las completions son lo más valioso que guarda mcpd y lo más peligroso. La ocultación es la etapa tres del pipeline de ingesta, por delante de la escritura, de modo que un valor que una regla capta nunca llega a la base de datos.

Ingest pipeline
  1. Decode

    OTLP or native, gzip or plain

  2. Sample

    Decided once, at the root

  3. Redact

    Before anything is written

  4. Price

    From the configured price book

  5. Store

    One transaction per batch

Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.

El orden es todo el asunto. Un valor limpiado a posteriori ha estado igual en disco, en el write-ahead log y muy posiblemente en la copia de seguridad de anoche. No haberlo escrito nunca es una garantía distinta de haberlo borrado después, y es la única que le puedes exponer con claridad a alguien que pregunte.

La ocultación está activada por defecto. Una herramienta que solo te protege una vez la has configurado no protege a nadie. --no-redact, o MCPD_NO_REDACT, la apaga, lo que solo tiene sentido cuando algo aguas arriba ya oculta.

La ocultación es el extremo del cable que sostiene el daemon. Los SDK sostienen el otro: MCPD_CAPTURE_PAYLOADS=false, o capture_payloads en código, mantiene los prompts y las completions dentro del proceso. Un span sigue llegando con sus tiempos, su estado, su uso, su modelo, su correlación y sus errores, e input y output se descartan antes de encolarlo, así que nunca cruzan la red. Un equipo que no puede enviar esos bytes define eso, y un span que no lleva nada sensible puede volver a activarlo por su cuenta.

Las reglas por defecto

Una regla es un conjunto de subcadenas en minúscula y un modo. Una clave coincide cuando su nombre, en minúscula, contiene alguna de ellas. Comparar por subcadena es la razón de que X-API-Key, authorization y customer_email queden captados sin tener que enumerar todas las mayúsculas y todos los prefijos.

ModoNombres de clave que coinciden
mask api_key, apikey, api-key, secret, password, passwd, access_token, refresh_token, id_token, bearer, authorization, auth_token, private_key, client_secret, session_token, credential, signature, x-api-key
partial email, phone, ssn, credit_card, card_number, iban
hash user_id, userid

Cuando una clave coincidente contiene un objeto o un array, todo lo que hay debajo se oculta también. {"credential": {"value": "hunter2", "note": "prod"}} pierde los dos campos, no solo aquel cuyo nombre parecía peligroso.

El límite conviene decirlo con claridad: las reglas comparan nombres de clave, no valores. Una clave de API que un usuario pegó en mitad de un prompt, almacenada bajo una clave llamada content, no la capta nada de aquí. La ocultación detiene la captura estructurada de credenciales e identificadores. No es un analizador de contenido, y tratarla como tal te va a decepcionar.

Los cuatro modos

ModoQué le pasa al valor
drop La clave se quita entera, como si nunca se hubiera enviado.
mask El valor se reemplaza por [redacted].
partial Sobreviven los dos primeros y los dos últimos caracteres, unidos por puntos suspensivos: alice@example.com pasa a ser al…om. Suficiente para reconocer un valor que ya conoces, no para usarlo. Un valor de seis caracteres o menos se enmascara entero, porque conservar cuatro de cinco caracteres revela casi todo.
hash El valor se reemplaza por sha256: y los primeros doce bytes de un hash SHA-256 con clave, en veinticuatro caracteres hexadecimales.

Las reglas integradas usan tres de los cuatro. Nada se descarta por defecto: drop está ahí para una regla que añadas tú, donde incluso la presencia de un campo es más de lo que quieres conservar.

lo que volvió a salir
$ curl -s "$MCPD/v1/api/traces/$TRACE" | jq .spans[0].input{  "question": "Where is my order A-1042?",  "api_key": "[redacted]",  "email": "al…om",  "user_id": "sha256:9f2c1b7d4a08e5c3f6b1d0a4"}

Por qué el hash lleva clave

Aplicar un hash mantiene el campo útil. Entradas iguales dan hashes iguales bajo una misma clave, así que todavía puedes agrupar por un id de usuario ocultado, contar cuántas personas distintas se toparon con un fallo y seguir a una de ellas a lo largo de una sesión, sin tener quiénes son.

Sin clave, esa propiedad es un agujero. Los identificadores vienen de espacios pequeños y adivinables, y cualquiera que tenga el hash y este código puede probar candidatos hasta que uno coincida. La clave es lo que hace el hash irrecuperable en lugar de meramente incómodo.

La clave integrada es una constante del código, así que es pública. Define la tuya en cualquier despliegue que aplique un hash a algo.

un despliegue que aplica hash y caduca datos

mcpd --redact-key "$MCPD_REDACT_KEY" --retention-days 30

Rotar la clave rompe el vínculo a propósito. Todos los hashes cambian, así que las filas escritas antes de la rotación ya no se pueden unir con las escritas después. Para eso sirve una rotación, y eso es también lo que cuesta. Rota cuando quieras el vínculo cortado, no por calendario y por costumbre.

Lee la segunda mitad de ese costo antes de rotar. La supresión por sujeto compara el hash almacenado, así que toda fila escrita bajo la clave vieja queda fuera del alcance de una solicitud de borrado desde el momento en que la clave nueva está en vigor. mcpd no te puede decir que eso es lo que ha pasado, porque nada anota qué clave escribió qué fila. Lo único que puede hacer es negarse a que el cero resultante pase por respuesta, y la sección de más abajo sobre el cero es esa negativa.

Todas las señales, no solo los spans

Un secreto se escapa igual de fácil por una línea de log que por un prompt, así que el mismo ocultador corre sobre las seis señales a la entrada:

  • Spans: input, output, attributes y metadata.
  • Logs: los fields estructurados.
  • Métricas: los labels.
  • Puntuaciones: el metadata.
  • Feedback: el metadata y el comment de texto libre.
  • Notas de aspecto: la frase, en text.

El nombre de un span y el mensaje de un registro de log no pasan por el ocultador. Son identificadores, no payloads, y reescribirlos rompería la agrupación. No construyas el nombre de un span con el correo de un cliente.

El almacén de ejecuciones no se oculta

Las ejecuciones duraderas son la excepción, y es deliberado. El almacén de ejecuciones guarda la entrada, el estado, la salida y la correlación de una ejecución, y la entrada, la salida y el error de cada paso, exactamente como se enviaron, con user_id sin hash. Son el estado de trabajo del workflow: un paso posterior los vuelve a leer, y un valor que el ocultador hubiera reescrito cambiaría lo que hace la ejecución. Los spans que una ejecución escribe en el almacén de trazas siguen la ruta de ingesta normal, así que el almacén de trazas los guarda ocultados y con hash como cualquier otro span. La retención y la supresión llegan las dos al almacén de ejecuciones, como dicen las secciones de abajo, y cada lectura de una ejecución por la API queda anotada en el registro de auditoría.

Cadenas largas y documentos profundos

Dos límites se aplican a todo, haya coincidido una regla o no.

Toda cadena de más de 128 KiB se trunca, con … [N bytes truncated] al final para que quien lo lea sepa que el valor está incompleto en lugar de ser corto. El corte cae en una frontera de carácter, nunca dentro de un punto de código de varios bytes. Un prompt desbocado no debería decidir el tamaño de tu base de datos.

El recorrido se detiene a una profundidad de 32, y todo lo que hay por debajo se reemplaza por [too deeply nested]. Un documento patológico o autorreferente que llegue a un puerto de ingesta abierto no puede clavar una CPU.

Retención

--retention-days, o MCPD_RETENTION_DAYS, borra las señales más viejas que ese número de días. Omítela y no se borra nunca nada. Conservar los datos por defecto es el sentido correcto: una herramienta que descartara en silencio las trazas del mes pasado se descubriría en el peor momento.

La pasada corre en un temporizador horario dentro del daemon, no en el momento de la consulta. Un barrido de retención puede borrar muchísimas filas, y ese trabajo nunca debe caer dentro de la solicitud de alguien. Cada pasada elimina, en una sola transacción, los spans, logs, métricas y filas de traza cuyo received_at es anterior al corte, y las puntuaciones, el feedback y las notas de aspecto cuya propia marca de tiempo lo es, y luego registra cuántas filas se fueron. Las entradas del índice de texto completo se limpian primero, mientras las filas que las nombran todavía existen, así que nada queda huérfano.

La misma pasada, con la misma ventana, recorta el almacén de ejecuciones. Borra toda ejecución duradera cuya raíz terminó antes del corte, con sus ejecuciones hijas y todo lo guardado para ellas, por lotes para no retener a los workers mientras trabaja. Una ejecución que no ha terminado no se borra nunca, lleve el tiempo que lleve esperando. Las definiciones no se recortan.

La pasada cubre el proyecto que nombra --project y todos los proyectos del bloque projects del archivo de configuración, no solo el que viene por defecto. Un proyecto que define ahí su propio retention_days se barre con esa cifra, y el resto se barren con la del daemon. Un proyecto que no está en ninguno de los dos sitios, uno que un emisor nombró por su cuenta, no tiene ventana y no se recorta nunca. Eso importa cuando un daemon sirve a varios equipos: un equipo que aceptó guardar treinta días no los guarda en silencio porque el ajuste de otro equipo fuera el único que leyó el barrido, y un equipo que nadie anotó lo guarda todo.

Una solicitud de supresión sobre una persona

DELETE /v1/api/subjects/{user_id} responde a una solicitud sobre una persona en una sola llamada. Envía el identificador tal como lo definió tu instrumentación. El daemon aplica el mismo hash que aplicó en la ingesta, así que un despliegue que aplica hash a user_id no te obliga a aplicarlo tú antes.

La unidad de supresión es la ejecución, no la fila. Normalmente solo un span raíz lleva un user_id, y los spans hijos son los que tienen el prompt y la respuesta, así que borrar las filas que nombran al sujeto dejaría el payload en pie. Una ejecución entra en el alcance cuando la proyección de su traza nombra al sujeto o cuando lo nombra alguno de sus spans, y entonces se va la ejecución entera:

  • Todos los spans de esa ejecución, con los inputs y outputs que llevaban.
  • Los logs, métricas, puntuaciones, feedback y notas de aspecto colgados de ella.
  • Las señales que nombran un span_id y ningún trace_id. Una puntuación o una línea de log clavada a un paso de la ejecución del sujeto se alcanza a través de los ids de span de las ejecuciones en el alcance, no solo a través de sus ids de traza.
  • Las líneas de log anotadas contra la persona sin ninguna ejecución de la que colgar, comparadas por su propio user_id.
  • El feedback que escribió la persona, comparado por author_id, cuelgue de donde cuelgue. Esa fila lleva su propia prosa, y puede colgar de la ejecución de otra persona, que es donde antes sobrevivía.
  • Los casos de dataset capturados de una ejecución en el alcance. Esos se vacían en lugar de borrarse. La sección de después de la siguiente dice por qué.
  • En un daemon que sirve ejecuciones duraderas, toda ejecución terminada cuya correlación lleve el user_id, comparado tal como lo pasaste al arrancar la ejecución porque el almacén de ejecuciones lo guarda sin hash, con todo su árbol de ejecuciones hijas y todo lo que el almacén de ejecuciones guarda para ellas. Una ejecución de esa persona que no ha terminado no se toca: borrarla corrompería el workflow del que forma parte. Se cuenta en runs_in_flight y se nombra en note, para que puedas cancelarla, o dejar que termine, y enviar la solicitud otra vez.

El almacén de ejecuciones se suprime primero. Los spans de una ejecución esperan en su outbox hasta que se escriben en el almacén de trazas, así que suprimir primero las ejecuciones borra cualquier span que todavía espere, y la supresión del almacén de trazas quita después los que ya se escribieron.

Qué lleva la respuesta

Un recuento por señal, dos recuentos para el almacén de ejecuciones, y seis campos al lado que llevan lo que un recuento no puede: qué se dejó atrás a propósito, y si un número pequeño se puede leer como «aquí no hay nada almacenado sobre esta persona». deleted es el total de los seis recuentos por señal; los recuentos de ejecuciones y aspect_notes_erased se informan a su lado, no se suman en él.

una solicitud, una persona
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq{  "project": "checkout",  "traces": 1,  "spans": 2,  "logs": 1,  "metrics": 0,  "scores": 1,  "feedback": 1,  "deleted": 6,  "runs_erased": 1,  "runs_in_flight": 0,  "dataset_items_erased": 1,  "aspect_notes_erased": 0,  "datasets": [    "checkout-regressions"  ],  "deletes_pending": false,  "unmatchable_identifiers": 0,  "note": "1 captured dataset cases were emptied in place rather           than deleted, so a regression test is not destroyed           without anyone being told. They hold no payload any           more."}
CampoQué te dice
runs_erased Ejecuciones duraderas borradas del almacén de ejecuciones, incluidas las ejecuciones hijas. No se cuentan en deleted. Ausente cuando el daemon no sirve ejecuciones
runs_in_flight Las ejecuciones duraderas de la persona que se dejaron enteras porque no han terminado. note nombra las ejecuciones que hay que cancelar. Ausente cuando el daemon no sirve ejecuciones
dataset_items_erased Casos capturados vaciados en su sitio. No se cuentan en deleted, porque las filas siguen ahí
aspect_notes_erased Notas de aspecto borradas. No se cuentan en deleted, que suma los seis recuentos por señal impresos encima
datasets En qué datasets están esos casos, para que puedas ir a mirarlos
deletes_pending Verdadero cuando el backend aplica los borrados después de responderte, de modo que los recuentos dicen qué coincidió y no qué ha salido ya del disco
unmatchable_identifiers Identificadores almacenados en este proyecto que están en una forma que esta solicitud no podía comparar
note Por qué los números de arriba pueden no ser la respuesta entera. Ausente cuando sí lo son

Los casos capturados se vacían, no se borran

Un caso de dataset capturado de producción lleva una copia literal del input, así que suprimir la ejecución y dejar la copia dejaría las palabras de la persona dentro del dataset. Borrar el caso sin más es un daño de otro tipo: alguien capturó esa ejecución para impedir que un error volviera, y destruir su prueba de regresión sin decir nada no es una decisión que mcpd deba tomar por su cuenta.

Así que el payload se sobrescribe y la fila sobrevive. El caso pasa a leer {"erased": true} donde estaban su input y su metadata, su enlace de vuelta a la ejecución de origen queda limpio, y la respuesta nombra cuántos casos y qué datasets. El caso está vacío a la vista, que es algo sobre lo que un operador puede actuar.

Un caso queda fuera de alcance del todo, y la razón es la tenencia. Un caso capturado en un dataset que pertenece a otro proyecto no se vacía y no se cuenta. La llamada está autorizada para un proyecto y toca un proyecto, y una supresión que cruzara esa frontera sería una forma de averiguar qué hay en un dataset que no puedes leer. Suprime a la persona también de ese proyecto, con una clave que llegue a él.

Qué no hace

Toca un solo proyecto. ?project= lo nombra y se usa el proyecto por defecto del daemon cuando lo omites. Una clave que no llega a ese proyecto se rechaza con un 403 y no se borra nada. Las filas de la misma persona en un proyecto que la clave no puede abrir no se tocan ni se cuentan, así que un recuento nunca puede revelar que el sujeto existe en otro sitio. Suprimir a una persona de varios proyectos es una llamada por proyecto, con una clave que llegue a cada uno.

Un sujeto sin nada almacenado es un 200 con ceros, no un 404. El estado que pediste se cumple, y un 404 respondería una pregunta que nadie tiene derecho a hacer: si esa persona existe en algún sitio que esta clave alcance. Es también la razón de que repetir tras un tiempo de espera agotado sea seguro. La segunda llamada informa de los recuentos menores de una segunda pasada en lugar de un error.

No es una forma de suprimir a una persona de tus propios sistemas. mcpd borra lo que mcpd almacenó. Todo lo que hayas reenviado a otro endpoint OTLP hay que suprimirlo allí también, y mcpd no sabe qué fue de ello.

Las claves y el registro de todo esto

La supresión necesita una clave de administración cuando hay claves configuradas, y recae en las claves de lectura cuando no hay clave de administración definida, de modo que un despliegue existente conserva el alcance que tenía. Queda escrita en el registro de auditoría con la acción, el proyecto y un hash de la clave que la pidió, y un intento rechazado se anota igual. Donde user_id lleva hash, el hash es lo que nombra la fila: el registro de auditoría sobrevive a los datos que describe, así que escribir ahí el identificador en claro dejaría el nombre que la persona te pidió quitar en una tabla que la retención no barre.

Una ejecución a la vez sigue estando disponible. DELETE /v1/api/traces/{trace_id} elimina una sola ejecución y todo lo adjunto a ella, en una transacción, y devuelve el número de filas borradas.

Un cero de una supresión es el valor del que desconfiar

Un cero es lo que un operador le repite a una persona como «no tenemos nada tuyo», y es el único número que este endpoint no puede demostrar. A los identificadores almacenados se les aplica un hash con la clave de ocultación, y nada anota qué clave le aplicó el hash a qué fila, así que un cero puede significar igualmente que la clave cambió después de escribir esas filas. mcpd no distingue los dos casos. Lo que sí puede hacer es no devolver jamás un cero pelado que se lea como el primero.

Aquí está el mismo sujeto, suprimido bajo una clave de ocultación que se ha rotado desde que se registró la ejecución. Los recuentos son todos cero y la fila sigue ahí:

tras una rotación, la misma solicitud no encuentra nada
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{  "deleted": 0,  "unmatchable_identifiers": 0,  "note": "Nothing matched. Stored identifiers are hashed with           the redaction key, so this means no stored identifier           hashes to this subject under the key in force now. It           does not mean nothing about this person was ever           stored: rows written under a different redaction key           cannot be matched by this endpoint."}

Lo que la nota puede demostrar depende del proyecto. Cuando nada en él está atribuido a nadie, no hay ninguna fila que un cambio de clave pudiera estar tapando, y la nota dice exactamente eso en su lugar. En otro caso el cero está sin demostrar: restaura la clave de ocultación bajo la que se escribieron esas filas y repite la solicitud.

Informa en lugar de negarse, y es un canje deliberado. Negarse rompería una repetición tras un tiempo de espera agotado, haría que la respuesta a «nunca visto» se diferenciara de la respuesta a «suprimido hace una hora» y convertiría el endpoint en una forma de preguntar si un proyecto conoce a una persona, y bloquearía todas las supresiones en un despliegue cuyo ajuste de ocultación haya cambiado alguna vez, incluidas las que correctamente no encontraron nada.

Identificadores que esta solicitud no podía comparar

Una rotación es invisible. Un cambio en si los identificadores llevan hash o no, no lo es, y unmatchable_identifiers es lo que mcpd sí puede ver. Un identificador con hash se reconoce por su prefijo sha256:, así que el almacén cuenta cuántos de los identificadores que tiene están en la forma contraria a la que llevaba esta solicitud: filas en claro contra una solicitud con hash, o al revés. Esas filas se escribieron bajo un ajuste de ocultación distinto, pueden pertenecer al sujeto, y esta solicitud no puede llegar a ellas.

El recuento corre en cada supresión, no solo cuando sale cero, y la respuesta de abajo explica por qué. A este proyecto se le escribió con la ocultación activada, y luego se le volvió a escribir con ella apagada. La solicitud encontró las filas nuevas y las borró, y los dos identificadores más viejos llevan hash, así que esta solicitud nunca podría haberlos alcanzado. Un recuento que se hubiera quedado en deleted se habría leído como una respuesta completa:

una supresión parcial, que es el caso que conviene detectar
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{  "deleted": 2,  "unmatchable_identifiers": 2,  "note": "2 stored identifiers in this project are in a form           this request could not match, so rows written under a           different redaction setting were not searched and rows           about this person may remain. Restore the redaction           setting those rows were written under and repeat the           request."}

El recuento es sobre todos los identificadores que tiene el proyecto, no sobre las filas propias del sujeto, así que esos dos pueden pertenecer a otra persona por completo. Es lo máximo que puede decir. Lo que descarta es leer una supresión de aspecto correcto como una supresión completa.

La garantía no es la misma en los tres backends

SQLite y Postgres borran dentro de la transacción que te respondió. Cuando llega la respuesta las filas ya no están, y deletes_pending es false.

ClickHouse no. Ahí el borrado es una mutación asíncrona: el servidor acepta la sentencia y la aplica en segundo plano, así que la respuesta dice qué coincidió y no qué ha salido ya del disco. Esas supresiones vuelven con deletes_pending puesto a true, que es mcpd diciéndote que la más débil de las dos garantías es la que está en vigor. Los casos de dataset son la excepción en ese backend. Se reescriben bajo su propia clave en lugar de mutarse, así que el caso vaciado es lo que devuelve cualquier lectura desde el momento en que se envía la respuesta. Una mutación habría dejado el texto viejo legible hasta terminar.

Si tienes que certificarle a alguien que los datos ya no están a partir de un momento dado, esa es la diferencia que hay que anotar. En ClickHouse, confirma en el servidor que la mutación terminó antes de responder.

Antes de que el puerto sea accesible

La ocultación protege los valores dentro de una traza. No hace nada respecto a quién puede leer la traza. mcpd avisa al arrancar cuando se enlaza a una dirección que no es de loopback con la ingesta abierta o la API de lectura abierta, porque los prompts y las completions almacenados quedan entonces legibles para cualquiera que alcance el puerto. Define --ingest-key y --read-key primero, y lee Docker y Kubernetes.

A continuación