Documentación
Referencia de Nazca v0.1. Cubre lo que existe hoy: ingesta, disociación y almacenamiento (M1), más el traductor MQL y la reconstrucción de trazas (M2 parcial). Lo que todavía no está construido se marca como tal.
CLI
Todo comando emite el mismo sobre JSON, también cuando falla. Un consumidor automático nunca tiene que distinguir la salida de éxito de la de error por su forma.
$ nazca version # versión y tamaño del esquema $ nazca schema # las 36 columnas, con tipo y nulabilidad $ nazca config check # carga y valida la configuración $ nazca trace-id MSG00001 # deriva el trace_id de un MSH-10
{
"ok": true,
"command": "trace-id",
"data": {
"hl7_control_id": "MSG00001",
"trace_id": "476d4682b65101dea81d206cbc0fb04e"
},
"error": null
}
config check informa de si la llave PHI está presente, nunca de su valor
ni de su prefijo. Sale con código 1 si la configuración no es válida, lo que lo hace
utilizable como comprobación previa en un arranque.
Configuración
Fuente única: nazca.toml como base y variables NAZCA_*
encima. El entorno gana siempre, porque en producción la llave y las credenciales del
object store llegan del gestor de secretos, no de un fichero versionado. Los niveles
se separan con doble guion bajo.
NAZCA_SERVICE_ENV=prod NAZCA_PHI_KEY=<64 caracteres hexadecimales> # Object store. Glifo habla S3 puro con endpoint configurable. NAZCA_STORE__ENDPOINT=https://oxidestore.interno NAZCA_STORE__BUCKET=nazca NAZCA_STORE__ALLOW_HTTP=false # Multi-empresa: sin definir en un despliegue con varias organizaciones. # NAZCA_COLIBRI__DEFAULT_ORG=hospital-central # NAZCA_COLIBRI__DEFAULT_PROJECT=his-prod # Desfase asumido cuando un MSH-7 no declara zona. Chile: -240 o -180. NAZCA_HL7__DEFAULT_UTC_OFFSET_MINUTES=-240 # Glifo escribe al llegar a 5.000 filas o a los 10 s, lo que ocurra primero. NAZCA_GLIFO__FLUSH_ROWS=5000 NAZCA_GLIFO__FLUSH_INTERVAL_SECS=10
Dos reglas se comprueban al arrancar y abortan el proceso si no se cumplen: con
service_env=prod tiene que haber llave PHI, y
store.allow_http tiene que ser falso — telemetría clínica sin TLS no es
una opción que convenga dejar disponible por descuido.
La llave PHI
HMAC-SHA256, mínimo 32 bytes en hexadecimal. Vive fuera del almacenamiento: si la llave viajara junto a los datos seudonimizados, la seudonimización no serviría de nada.
$ openssl rand -hex 32
Sin llave el proceso no arranca, en ningún entorno. Tampoco se genera una al vuelo en desarrollo: una llave efímera produciría pseudónimos distintos en cada reinicio, y un pseudónimo inestable no permite reconstruir un episodio. Peor todavía, daría la impresión de que el sistema funciona.
Rotarla desune los pseudónimos anteriores de los nuevos. La política de rotación es una decisión abierta.
Organización y proyecto
Cada evento se escribe bajo <org_id>/<project_id>/…. El
destino se resuelve en este orden, e independientemente para cada dimensión:
- Cabecera explícita:
x-nazca-orgyx-nazca-project. En gRPC, los mismos nombres como metadatos. - Atributo de recurso OTLP:
nazca.orgynazca.project. - Valor por defecto de la configuración.
Si tras las tres no hay valor, la petición se rechaza con 400. Adivinar la
organización significa escribir datos clínicos de una empresa en el prefijo de otra, y
el destino es un almacenamiento WORM: eso no se deshace.
El destino resuelto se impone sobre lo que traiga el evento. Un emisor
no puede escribir en el prefijo de otra empresa poniendo otro org_id en el
cuerpo de la petición.
Ingesta nativa
POST /v1/events en :4318, para emisores que no hablan OTLP y
no se pueden modificar para que lo hablen. Se acepta un objeto con events
o una lista suelta, porque los clientes HTTP más rudimentarios generan lo segundo.
$ curl localhost:4318/v1/events \ -H 'x-nazca-org: hospital-central' \ -H 'x-nazca-project: his-prod' \ -H 'content-type: application/json' \ -d '{"events":[{ "signal": "span", "span_name": "canal ADT_ENTRADA", "duration_ns": 812000000, "status_code": "ERROR", "mirth_channel": "ADT_ENTRADA", "hl7_message": "MSH|^~\\&|HIS|...|ADT^A31|MSG00001|T|2.5\rPID|1||11111111^^^HIS^MR||..." }]}'
El campo hl7_message
El más útil del formato. Se manda el mensaje HL7v2 crudo y Nazca extrae MSH-3, MSH-5,
MSH-9, MSH-10 y MSH-7, seudonimiza el PID-3 y deriva el
trace_id del MSH-10. El mensaje crudo no se almacena: se usa, se
extrae lo que correlaciona y se descarta antes de llegar al buffer.
Lo que venga explícito en el evento gana sobre lo extraído del mensaje: si un canal se
molestó en mandar su propio hl7_control_id corregido, sabe algo que el
mensaje no dice.
Campos desconocidos
No se descartan: acaban dentro de attributes. Perder en silencio un campo
que un emisor clínico añadió a propósito es peor que guardarlo de más.
Códigos de respuesta
| Código | Significa | ¿Reintentar? |
|---|---|---|
| 200 | aceptado | — |
| 400 | falta el destino, señal inválida, HL7 ilegible, trace_id malformado | no, arreglar el emisor |
| 429 | contrapresión: el buffer está lleno | sí, esperar |
| 503 | el sink está cerrado | sí |
La distinción importa: para un canal de integración, confundir un 429 con un 400 produce o duplicados o pérdida.
Ingesta OTLP
OTLP/gRPC en :4317 y OTLP/HTTP en :4318, este último en
protobuf y JSON. Rutas estándar: /v1/logs, /v1/traces,
/v1/metrics.
La conversión ocurre por recurso, no por petición: cada
ResourceLogs, ResourceSpans o ResourceMetrics
resuelve su propio destino. Un único colector puede recibir de varios sistemas, y de
varias empresas, y repartirlos correctamente.
Métricas admitidas
Solo Gauge y Sum. Los histogramas necesitan un modelo de
cubetas que el esquema único no tiene, y añadir columnas por adelantado sería pagar el
coste sin usarlo. Los puntos que no encajan se cuentan y se descartan,
y el número vuelve al emisor en el partial_success de la respuesta:
responder 200 a secas dejaría al emisor creyendo que llegó todo.
Convenciones semánticas
Un servicio que ya exporta OTLP no necesita cambiar de protocolo para participar en la correlación clínica. Basta con añadir atributos con estos nombres, que Nazca promueve a sus columnas — los mueve, no los copia, para que el dato no quede duplicado en cada fila.
| Atributo | Columna | Nota |
|---|---|---|
hl7.message_type | hl7_message_type | MSH-9 |
hl7.control_id | hl7_control_id | de aquí sale el trace_id |
hl7.sending_app | hl7_sending_app | MSH-3 |
hl7.receiving_app | hl7_receiving_app | MSH-5 |
hl7.patient_id | hl7_patient_ref | se seudonimiza al promoverlo |
mirth.channel | mirth_channel | eje de la vista de canal |
mirth.message_id | mirth_message_id | salto a la consola del motor |
db.instance | db_instance | servidor o instancia |
db.namespace | db_namespace | SID/PDB, base de datos |
db.statement_id | db_statement_id | SQL_ID, queryid, query_hash |
Se reconocen además las convenciones estándar service.name,
deployment.environment[.name] y host.name.
Neutralidad de motor
Las tres columnas db_* no asumen Oracle. La pregunta es la misma en los
tres motores —qué sentencia fue y qué hizo— y por eso la columna es una sola:
| Columna | Oracle | PostgreSQL | SQL Server |
|---|---|---|---|
| db_instance | servicio o instancia | servidor | instancia nombrada |
| db_namespace | SID o PDB | base de datos | base de datos |
| db_statement_id | SQL_ID → AWR | queryid | query_hash |
Esquema columnar
Una sola tabla de 36 columnas para logs, spans y métricas. La unificación es deliberada: tres tablas separadas obligan a un join entre almacenes para responder lo que de verdad importa.
Solo cuatro columnas son no nulas:
timestamp Timestamp(us, UTC) no nulo org_id Utf8 no nulo — frontera entre empresas project_id Utf8 no nulo — único dentro de la organización signal Utf8 no nulo — log | span | metric
El resto se agrupa en: comunes (observed_timestamp,
service_name, service_env, host_name), log
(severity_text, severity_number, body), traza
(trace_id, span_id, parent_span_id,
span_name, span_kind, duration_ns,
status_code), métrica, dominio clínico (hl7_*,
mirth_*), infraestructura (db_*), libre
(attributes, resource_attributes, JSON) y procedencia
(ingest_id, redacted).
nazca schema lo imprime completo con tipos y nulabilidad.
Disociación PHI
Ningún identificador directo de paciente se escribe jamás en el almacenamiento. La seudonimización ocurre dentro del receptor, antes del buffer y antes del Parquet.
pseudonym(valor, dominio) = "px_" + hex(HMAC-SHA256(llave, dominio ‖ 0x1F ‖ valor))[..24]
El separador 0x1F evita la ambigüedad de concatenación: sin él,
(rut, x) y (ru, tx) producirían el
mismo pseudónimo. Los dominios están separados para que el pseudónimo del RUT de una
persona y el de su correo no coincidan.
Barrido de texto libre
Se aplica en este orden, que es normativo:
- Segmentos
PID|completos →PID|[segmento-disociado]|px_…, con el pseudónimo del PID-3 para conservar la correlación. - Correo → pseudónimo.
- Teléfono chileno →
[tel-redactado]. Exige+o separador tras el56; sin eso, un56suelto casa dentro de cualquier identificador largo. - RUT → pseudónimo.
Si el RUT fuera antes que el teléfono, se comería parte de un
+56 9 1234 5678.
RUT: las tres formas
| Forma | Regla |
|---|---|
12.345.678-9 | siempre, aunque el dígito verificador esté mal |
12345678-K | siempre |
123456789 | solo con verificador válido (módulo 11) |
Los puntos y el guion son señal de que alguien escribió un RUT, y los de prueba suelen llevar el verificador mal. Sin puntos ni guion no hay señal alguna: sin la comprobación del verificador, cualquier identificador interno de nueve dígitos —y un HIS está lleno de ellos— se convertiría en pseudónimo.
Claves estructuradas
Cuando la clave de un atributo anuncia que su valor es un identificador, se actúa sobre
la clave. Atrapa lo que el barrido de texto no puede ver, como
{"nombre": "Juan Pérez"}, donde el valor no tiene forma reconocible.
Vigiladas: rut, run, dni,
documento, patient_id, pid, email,
nombre, name, apellido, telefono,
direccion, fecha_nacimiento, dob. La comparación
es sobre la clave normalizada y sus sufijos de segmento, así que
x-patient-id y http.request.body.email también caen.
Hay una lista de excepciones para claves técnicas que colisionan:
service.name, host.name, process.pid y similares.
Sin ella, enmascarar service.name haría perder la columna por la que se
filtra prácticamente toda consulta.
Seudonimizar o enmascarar
RUT, RUN, DNI, documento, patient_id y correo reciben pseudónimo estable:
correlacionarlos consigo mismos tiene utilidad clínica. Nombre, apellido, dirección,
teléfono y fecha de nacimiento reciben marcador fijo: no responden ninguna pregunta de
integración, y un pseudónimo de ellos solo sería superficie de ataque.
MQL
Sintaxis tipo KQL. El traductor es un parser real sobre la gramática, nunca concatenación de cadenas.
service == "api-admisiones" and severity >= error | last 1h hl7_message_type == "ADT^A31" and status == "ERROR" | last 24h | count by channel duration_ns > 5e9 | last 7d | percentile(duration_ns, 99) by span_name trace: 3f9a2b1c4d5e6f708192a3b4c5d6e7f8 patient: px_a1b2c3d4e5f60718293a4b5c
Operadores
== != > >= < <= campo contains "texto" campo in ("a", "b") campo is null campo is not null and or not ( )
and liga más fuerte que or. Una conjunción de primer nivel se
aplana en filtros independientes, para que el motor pueda empujarlos por separado hacia
el almacenamiento.
Etapas
| last <30s|15m|1h|7d> | count [by campo, …] | distinct(campo) | sum | avg | min | max | percentile(campo, 99) [by campo] | asc | desc # por defecto, desc | limit <n>
Cada etapa puede aparecer una sola vez. | last 1h | last 2h
se rechaza en lugar de que gane una de las dos en silencio. trace: y
patient: no admiten agregación: ya describen la forma del resultado.
Abreviaturas
service, severity, env, host,
duration, status, channel/canal,
message_type, control_id, metric,
span. Los nombres de severidad se escriben como se dicen y se traducen al
número OTLP: trace=1, debug=5, info=9,
warn=13, error=17, fatal=21.
Tres garantías
- No hay inyección posible. Todo nombre de campo se resuelve contra el esquema; un identificador que no esté ahí no produce un campo, y sin campo no hay predicado. Los literales viajan como parámetros.
- No hay recorrido completo. Sin
lastexplícito se asumelast 1h, y la ventana no es opcional en el plan. org_idyproject_idno se pueden filtrar. Los aporta la sesión. Permitirlo daría la falsa impresión de que se eligen: escribirorg_id == "otra"y recibir cero haría creer que esa empresa no tiene datos.
El tope de ventana es de 31 días. Los errores llevan posición: «MQL inválido en la
posición 24», y los de resolución dicen qué hacer: «columna desconocida
"rut_paciente"; use nazca schema para ver las disponibles».
Almacenamiento
<root>/<org_id>/<project_id>/dt=YYYY-MM-DD/hr=HH/<signal>-<epoch_ms>-<uuid8>.parquet
El orden de los segmentos está elegido para las consultas que de verdad se hacen. Con la organización primero, el aislamiento entre empresas es un prefijo del object store, no una cláusula que alguien pueda olvidar. Con la fecha y la hora al final, la poda de particiones descarta la mayoría de los ficheros sin abrir ninguno.
La hora es partición propia y no un filtro: el patrón dominante es «qué está pasando ahora», y con partición solo diaria esa consulta leería 24 veces más ficheros.
Compresión zstd, row group objetivo de 128 MB, y estadísticas
de columna solo en timestamp, service_name,
trace_id, hl7_control_id y severity_number — las
que aparecen en los predicados reales. Habilitarlas en las 36 engordaría el pie del
fichero, que se lee entero en cada consulta.
El sufijo aleatorio permite que varias instancias escriban la misma partición sin coordinarse: en un almacenamiento WORM, dos ficheros con el mismo nombre serían pérdida de datos irreversible.
Un fichero nunca contiene filas de dos empresas, porque la organización forma parte de la clave de agrupación del buffer.
Despliegue
Dos binarios. nazca-colibri es el receptor; nazca-api sirve
las consultas y la consola.
$ cargo build --release --target x86_64-unknown-linux-musl --bin nazca-colibri $ cargo build --release --target x86_64-unknown-linux-musl --bin nazca-api
Colibrí escucha en :4317 (gRPC) y :4318 (HTTP), y necesita
alcanzar el object store. La API escucha en :8080. Ambos fallan al arrancar
si la configuración no es válida, lo que es deliberado: es mejor que no arranquen a que
arranquen sin disociar.
El apagado es ordenado: al recibir la señal, Colibrí drena el buffer para que lo ya ingerido llegue al Parquet antes de terminar.
Entorno local
$ docker compose up -d # MinIO como sustituto de OxideStore $ cp .env.example .env $ echo "NAZCA_PHI_KEY=$(openssl rand -hex 32)" >> .env $ cargo run --bin nazca-colibri