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:

  1. Cabecera explícita: x-nazca-org y x-nazca-project. En gRPC, los mismos nombres como metadatos.
  2. Atributo de recurso OTLP: nazca.org y nazca.project.
  3. 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ódigoSignifica¿Reintentar?
200aceptado
400falta el destino, señal inválida, HL7 ilegible, trace_id malformadono, arreglar el emisor
429contrapresión: el buffer está llenosí, esperar
503el sink está cerrado

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.

AtributoColumnaNota
hl7.message_typehl7_message_typeMSH-9
hl7.control_idhl7_control_idde aquí sale el trace_id
hl7.sending_apphl7_sending_appMSH-3
hl7.receiving_apphl7_receiving_appMSH-5
hl7.patient_idhl7_patient_refse seudonimiza al promoverlo
mirth.channelmirth_channeleje de la vista de canal
mirth.message_idmirth_message_idsalto a la consola del motor
db.instancedb_instanceservidor o instancia
db.namespacedb_namespaceSID/PDB, base de datos
db.statement_iddb_statement_idSQL_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:

ColumnaOraclePostgreSQLSQL Server
db_instanceservicio o instanciaservidorinstancia nombrada
db_namespaceSID o PDBbase de datosbase de datos
db_statement_idSQL_ID → AWRqueryidquery_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:

  1. Segmentos PID| completosPID|[segmento-disociado]|px_…, con el pseudónimo del PID-3 para conservar la correlación.
  2. Correo → pseudónimo.
  3. Teléfono chileno[tel-redactado]. Exige + o separador tras el 56; sin eso, un 56 suelto casa dentro de cualquier identificador largo.
  4. 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

FormaRegla
12.345.678-9siempre, aunque el dígito verificador esté mal
12345678-Ksiempre
123456789solo 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

  1. 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.
  2. No hay recorrido completo. Sin last explícito se asume last 1h, y la ventana no es opcional en el plan.
  3. org_id y project_id no se pueden filtrar. Los aporta la sesión. Permitirlo daría la falsa impresión de que se eligen: escribir org_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