Amedi no arranca de cero. PostHog ya vive en la API y en dos de las tres webs, y el audit trail
app-layer —$extends de Prisma, contexto ALS, buffer y una redacción fail-closed—
ya escribe en audit_logs sobre los once modelos que tocan PHI. Lo que no existe es
el hilo: nada correlaciona una línea de log con la excepción que la siguió ni con la fila de
auditoría que dejó. Hoy, cuando algo se rompe, el camino es abrir tres pestañas y adivinar.
Lo que no se toca
- PostHog en la API —
AnalyticsServiceyPosthogExceptionFilterde 5xx víaAPP_FILTER, con gating a producción. - PostHog en client y consultorio —
lib/analytics/:PHProvider, puente de identidad,error.tsx→reportError, rewrites/ingesty el guardcheck-analytics.ts. - Audit trail app-layer completo —
audit-prisma.extension.ts+audit-context+audit-buffer+redaction.tsfail-closed (keep/hash/<redacted>) sobre 11 modelos con PHI →audit_logs.redactedDiff. PatientAccessLog(lecturas) yPermissionAuditLog(roles).- El patrón ALS ya consolidado — demo-context, audit-context y audit-buffer, todos con
run(). multiSchemaactivo en Prisma y un precedente vivo de schema fuera de Prisma:demo.
Lo que hay que coser
- Logs. 65 usos del
Loggerde Nest y 7console.*; sin pino, sin JSON, sinrequest_id, sin OTLP.main.tsno tienebufferLogsniuseLogger. - Request id. Inexistente. Peor:
allowedHeadersdel CORS es una allow-list explícita que bloqueax-request-idyx-posthog-session-id. - Errores de la API. Cuatro crons se tragan el error en el logger; no hay
unhandledRejectionniuncaughtException; el filtro no adjunta id ni sesión. - Web. El admin tiene cero PostHog; ningún
global-error.tsxen client ni consultorio; el session replay sin configurar en código (IN-015: riesgo de PHI, masking pendiente). - Join sesión ↔ request. Nadie envía
x-posthog-session-id; elbeforeRequestde ky es el punto de inyección en las tres apps. - Auditoría. Puntos ciegos documentados:
createMany/updateMany/deleteMany, SQL directo, seeds y ~100 modelos sin instrumentar. IN-007 sigue abierto yAIAuditInterceptores solo unlogger.debugcon nombre engañoso. - Paquete compartido. No existe
@workspace/observability; la lógica de PostHog está duplicada en client y consultorio, y la API tiene la suya. - Docs.
API_PATTERNS.mdyCLAUDE.mdsin una línea de logging; CB-007 marcado abierto pero implementado;posthog-setup-report.mdobsoleto.
Observabilidad no es «mandar todo a un sitio». Son tres planos con reglas distintas que nacen de la misma request: los logs son de alto volumen y retención corta, los errores son pocos pero alertables, y la auditoría es inmutable y de retención larga. Confundirlos es el error clásico — guardar logs para siempre sale carísimo, y alertar sobre cada línea es ruido. Lo único que comparten es la llave.
Una request, tres destinos
Logs
Qué pasó, en orden
- Volumen
- Alto — una línea canónica por request
- Retención
- Corta
- Lector
- Humano o agente filtrando por
request_id - Destino
- stdout de Render + PostHog Logs (OTLP)
Errores
Qué se rompió, y a quién avisar
- Volumen
- Bajo — alertable sin ahogar
- Retención
- Media, agrupada por issue
- Lector
- Slack
#amedi-alerts+ agente - Destino
- PostHog Error Tracking, con
$session_idpara el replay
Auditoría
Quién tocó qué, y con qué intención
- Volumen
- Medio — solo escrituras
- Retención
- Larga e inmutable
- Lector
- Compliance, nunca debugging
- Destino
audit_logs+audit.record_version, solo metadatos y diff redactado
La regla que ordena los tres. El volumen se corta en la fuente (LOG_LEVEL,
autoLogging.ignore) y la retención se decide en el almacén — nunca al revés. Y ninguno
de los tres planos recibe PHI cruda: los logs redactan authorization, cookie
y los campos sensibles del body; los errores llevan mensaje saneado; la auditoría guarda nombres
de columna y un diff ya redactado.
Diagrama 01
Los tres planos con su volumen, su retención y su lector. Nacen del mismo request y solo comparten
el request_id — que es exactamente lo que permite saltar de una línea de log al error y
de ahí a la fila que quedó escrita.
El id se resuelve en un middleware de Nest, antes de los guards — no en un interceptor — porque
un 401 o un 429 también necesitan id. pino-http lo genera o lo adopta del header entrante;
el middleware lo mete en el AsyncLocalStorage junto con el sessionId de PostHog, y de ahí
en adelante nadie pasa el id por parámetro: quien lo necesite llama a
getCurrentRequestId(). DemoContextInterceptor sigue siendo el más externo.
De un header a cuatro consumidores
x-request-id del cliente, o randomUUID() si no viene. El CORS abre
allowedHeaders a x-request-id y x-posthog-session-id.
Normaliza (≤128) y lo deja en req.id. Mismo contrato de orden que en mogos:
pino genera, el middleware adopta.
Lee x-posthog-session-id (≤64, sin fallback) y abre
runWithRequestContext({ requestId, sessionId }, next).
Todo el árbol lee getCurrentRequestId() desde el ALS. Cero prop drilling
de contexto por la capa de dominio.
Una por request, con request_id, sessionId, userId
e is_demo.
tags.request_id y extra.$session_id — el $session_id es
lo que engancha el replay.
Columna nueva, la escribe el $extends que ya existe.
El trigger lo lee de set_config('app.request_id', …, true).
Por qué el eco importa. Con el id en la respuesta, un doctor que reporta «me falló al guardar la consulta» puede pegar un código de 36 caracteres y el equipo salta directo a su línea, a su excepción y a su fila de auditoría. Sin él, el soporte empieza por preguntar la hora.
Diagrama 02
El recorrido completo del request_id: entra por el header, lo genera pino-http,
lo adopta el middleware en el ALS y lo consumen cuatro escritores distintos antes de volver al cliente
por header y por cuerpo de error.
// stdout de Render · la misma línea se envía por OTLP a PostHog Logs {"level":"info","time":"2026-08-23T14:07:12.481Z","service.name":"amedi-api","deployment.environment":"prod","request_id":"8f3c1d92-7b40-4e1a-9a55-2c6f0d3ba91e","sessionId":"0198f2c1-4d33-7a10-b6ec-9f2e5c1a77d0","userId":"c4a1b7e0-3f28-4d5b-8c11-77aa2e40b3d9","actingAsDoctor":null,"is_demo":false,"req":{"method":"POST","url":"/appointments"},"statusCode":201,"responseTime":142,"msg":"request completed"}
Llaves planas y en el nivel de arriba: request_id, no req.id.value —
así se filtra igual en PostHog Logs, en los logs de Render y con grep. Ni el body ni los
query params entran: el redact de pino tumba authorization, cookie,
set-cookie y los campos del body con PHI antes de serializar.
Middleware antes que guards; DemoContextInterceptor sigue siendo el más externo
de los interceptores. El DemoRequestLoggerInterceptor se retira: la línea canónica ya
trae is_demo y hacía el mismo trabajo peor.
El principio del dominio es tajante: instrumenta librerías compartidas, nunca aplicación por aplicación. Hoy hay tres implementaciones de lo mismo (client, consultorio y la API) y una app —el admin— sin nada. La respuesta es un paquete con puertos y adaptadores: el código de producto habla contra una interfaz y el adaptador lo elige el entorno. El admin entra gratis, y es a propósito el primer consumidor limpio: si el paquete le sirve sin bypasses, el paquete sirve.
Puertos y adaptadores · el entorno decide
El AnalyticsService de la API pasa a ser fachada del puerto: misma API
pública, mismo events.ts. Los hooks de las webs mantienen su firma — los
componentes no cambian.
Interfaces en ports/. Es la única superficie que el producto conoce; los
adaptadores son intercambiables sin tocar un solo componente.
Todo devuelve void. Los tests no dependen de red ni de una key.
Imprime el evento con formato. El desarrollador ve lo que se habría enviado.
posthog-node en la API, posthog-js en las webs.
La regla dura: la telemetría nunca lanza. Cada método del adaptador vive dentro de un
try {} catch {}. Si PostHog está caído, si la key es inválida, si el fetch expira — el
producto no se entera. Una cita no se puede perder porque falló la analítica.
Diagrama 03 Un solo camino de código hacia el puerto y tres adaptadores detrás, elegidos por entorno. Cambiar de proveedor —o apagarlo— es cambiar la factory, no las 4 aplicaciones.
El scrub es de Amedi, no genérico. A los patrones heredados de mogos se suman los que importan
acá: cedula, /\bci\b/, diagnos, historia,
phone, whatsapp y email fuera del identify.
Sin sorpresas en el deploy
tsca CommonJS, con el mismo tsconfig y el mismo build queai-knowledge— el precedente que ya funciona en el monorepo.- Dockerfile de la API:
COPY package.jsonen los dos stages, build del paquete yCOPYdeldist. - Next apps:
transpilePackagesmás un aliasposthog-node: falseen el bundle de cliente, para que el adaptador de servidor no viaje al navegador.
Un módulo, tres providers
- API:
ObservabilityModulecon@Global()proveeERROR_TRACKERyANALYTICS. - Webs:
ObservabilityProvidersobre la factory de navegador y dos hooks,useErrorTrackeryuseAnalytics. lib/analytics/track.tsse reescribe encima del puerto sin cambiar su firma: cero migraciones en las pantallas.
Los 65 sitios que hoy llaman al Logger de Nest no se tocan. Se cambia el transporte,
no la API: nestjs-pino se instala debajo con bufferLogs y
useLogger(app.get(Logger)), y todas esas líneas salen en JSON con su request_id
sin abrir 65 archivos. La misma línea se escribe dos veces, a la vez: a stdout —donde
Render la recoge— y por OTLP a PostHog Logs.
Un origen, dos destinos simultáneos
Más los 7 console.* que se migran al Logger. Ni una firma cambia.
quietReqLogger, customAttributeKeys.reqId = 'request_id',
customProps con userId, actingAsDoctor,
sessionId e isDemo.
En dev, pino-pretty. La consola del servicio nunca se queda muda, aunque no haya
endpoint OTLP configurado.
Gate: la presencia de OTEL_EXPORTER_OTLP_LOGS_ENDPOINT. Atributos de recurso
service.name = amedi-api y deployment.environment.
El volumen se corta en la fuente. LOG_LEVEL decide qué se serializa
siquiera, y autoLogging.ignore saca del camino el ruido que no dice nada:
/, /health, /docs* y /favicon.ico. Lo que no se
emite no cuesta ni ancho de banda ni retención.
Diagrama 04 El pipeline completo. El segundo destino está gated por una variable de entorno: sin ella la API sigue logueando a stdout exactamente igual, así que el rollout no puede romper el servicio.
Puro, y por eso testeable
resolveLogLevel()— deLOG_LEVELy el entorno.normalizeRequestId()— recorta a 128 caracteres.normalizeSessionId()— recorta a 64, sin fallback: si no hay sesión, no se inventa una.buildTransportTargets()— decide uno o dos destinos.buildLoggerOptions()— arma todo lo anterior en las opciones de pino.- Exporta también
REQUEST_ID_HEADERyPOSTHOG_SESSION_ID_HEADER, que el CORS demain.tsimporta en vez de repetir el string.
El redact, escrito una vez
- Headers:
authorization,cookie,set-cookie. - Body: los campos con PHI, con la misma lista que ya usa
redaction.tsen el audit trail. - Nada de objetos de error completos en los handlers de proceso: mensaje y stack, nunca el objeto.
- El scrub del paquete corre además sobre las propiedades de los eventos, por si algo se cuela por otra vía.
Un error se pierde por tres razones: nadie lo capturó, se capturó sin contexto, o se capturó en una superficie que no reporta. Amedi tiene las tres. La matriz de abajo es el contrato: cada superficie con los mecanismos que le tocan, y ninguna celda vacía por olvido — las que quedan vacías es porque ese mecanismo no aplica ahí.
| Superficie | Filtro 5xx | Crons | process.on | global-error | error.tsx | Session replay |
|---|---|---|---|---|---|---|
| api · NestJS | ✓ | ✓ | ✓ | — | — | — |
| client · paciente | — | — | — | ✓ | ✓ | ✓ |
| consultorio · doctor | — | — | — | ✓ | ✓ | ✓ |
| admin · interno | — | — | — | ✓ | ✓ | ✓ |
Qué hay detrás de cada columna. Filtro 5xx: el PosthogExceptionFilter
existente, que gana tags.request_id, extra.$session_id y un mensaje saneado
en el wire. Crons: TrackedCronService.runTracked(jobName, logger, fn) en los
cuatro que hoy se tragan el error, con tags.job. process.on:
unhandledRejection y uncaughtException en main.ts — loguean
mensaje y stack (nunca el objeto), capturan, hacen flush y no matan el proceso.
global-error: el boundary raíz de Next, con posthog-js directo porque a ese
nivel los providers ya no existen — el bypass queda documentado en el propio archivo.
error.tsx: por el puerto, vía ObservabilityErrorBoundary.
El replay y su masking, app por app
| App | Configuración de replay | Por qué esa y no otra |
|---|---|---|
| client | maskInputOptions: { password: true, email: true } |
El paciente escribe datos suyos en los campos; el resto de la pantalla es navegación y contenido público. Enmascarar todo dejaría el replay ilegible sin ganar nada. |
| consultorio | maskAllInputs: true · maskTextSelector: '*' |
Hay PHI en pantalla, no solo en los inputs: nombres, diagnósticos, historia. El enmascarado total es la única opción defendible; se conserva la forma de la interacción, no el contenido. |
| admin | maskAllInputs: true · sin banner · opt_out_capturing_by_default: false |
Panel interno con datos de terceros a la vista. No hay banner porque no hay usuario externo a quien pedirle consentimiento, y la captura entra activa por defecto. |
El riesgo abierto de PHI en session replay se cierra con configuración escrita en código, no
con una nota. Además, el beforeRequest de ky manda
X-POSTHOG-SESSION-ID = posthog.get_session_id() en los clientes api y
publicApi de las tres apps — seis puntos de inyección — que es lo que permite saltar del
error del servidor al replay del navegador que lo provocó.
El error tracking sigue restringido a producción: fue una decisión consciente por el ruteo a Slack y
se conserva, ahora con deployment.environment como atributo explícito. Los logs
por OTLP sí salen en dev, etiquetados como tal — es donde se prueba el pipeline antes de que importe.
El trail que existe es bueno y tiene un techo conocido: vive en el $extends de Prisma, así
que todo lo que no pasa por Prisma no deja rastro — createMany,
updateMany, deleteMany, el SQL directo, los seeds y unos cien modelos sin
instrumentar. La red de seguridad va donde nadie la puede saltar: en la base de datos, con triggers.
Opción B: triggers con to_jsonb(OLD) y to_jsonb(NEW), guardando la
imagen completa de cada fila — el patrón que corre hoy en mogos. Es más potente: permite reconstruir
el estado exacto y hacer diff de valores sin instrumentar nada más.
Y por eso mismo no entra. Amedi eliminó oldValues y newValues de
audit_logs a propósito (RISK-004): copiar filas enteras de
medical_records o consultation_records a una tabla de auditoría es duplicar
PHI cruda en un sitio de retención larga que nadie mira a diario. Reintroducirla por la puerta de
atrás de un trigger sería deshacer una decisión de compliance para ganar comodidad de debugging.
La opción A guarda solo nombres de columna: quién tocó qué campo, nunca con qué valor.
Dos capas, una sola transacción
Decorador @AuditTrail(entity, description, { action, entityIdParam, captureBody })
sobre los endpoints que importan; el interceptor (APP_INTERCEPTOR) es
fire-and-forget y solo actúa en lo decorado.
Escribe actor, ip, user-agent, endpoint e intención —la descripción legible— junto al
redactedDiff que ya produce el $extends, ahora con
requestId.
Se llama AuditTrailInterceptor y no AuditInterceptor porque
AIAuditInterceptor ya ocupa ese nombre y está documentado como engañoso.
Schema audit por SQL crudo, fuera de datasource.schemas — el mismo
precedente que demo. Trigger SECURITY DEFINER con
SET search_path = ''.
Columnas: table_name, record_id, op,
changed_columns text[], actor_id, actor_label,
request_id, ts. Sin old_record ni
record.
changed_columns son las claves donde
to_jsonb(OLD)->k IS DISTINCT FROM to_jsonb(NEW)->k: solo los nombres.
Y REVOKE UPDATE, DELETE, TRUNCATE FROM PUBLIC, para que la red no se pueda editar.
Por qué hacen falta las dos. El trigger sabe que la fila cambió pero no sabe por qué
ni quién apretó el botón; el interceptor sabe la intención pero no ve el updateMany
que pasó por debajo. Juntas, y unidas por el request_id, no queda hueco — y ninguna de
las dos guarda un valor de PHI.
Diagrama 05
Las dos capas del trail y el endpoint que las lee. audit.enable_tracking(regclass) es
idempotente y arranca sobre las once tablas con PHI ya instrumentadas más las de roles y permisos; un
spec parsea schema.prisma para excluir las tablas con OTP o códigos vivos, que no
deben dejar ni el nombre de la columna en un log de larga retención.
Esta sección supersede IN-007 (pgaudit): los triggers cubren el mismo hueco con menos
superficie operativa y sin PHI. Cierra CB-007, que estaba marcado abierto pero ya
implementado. Y la verificación es un grep:
grep -c "old_record" migrations tiene que dar 0.
Cada fila es una bifurcación real con su camino descartado escrito al lado. Nada de esto es reversible barato una vez que hay datos escritos, así que queda por escrito antes de la primera migración.
| Tema | Opciones | Decisión | Por qué |
|---|---|---|---|
| Audit trail | A · triggers sin row images (metadatos + changed_columns[])B · triggers con to_jsonb(OLD/NEW), tipo mogosC · fuera de alcance |
A | El trail app-layer con redacción ya existe y RISK-004 quitó las row images a propósito. Los triggers cierran los puntos ciegos sin PHI cruda. Supersede IN-007 (pgaudit). |
| Logger | A · nestjs-pino como transporte, 65 call sites intactosB · migrar los call sites a un logger nuevo |
A | Mismo resultado —JSON con request_id en cada línea— sin tocar 65 archivos: useLogger(app.get(Logger)) más bufferLogs. |
| Request id | A · middleware de Nest antes de los guards + ALS B · interceptor |
A | Un 401 o un 429 también necesitan id, y el interceptor corre demasiado tarde. DemoContextInterceptor debe seguir siendo el más externo. |
| Paquete compartido | A · packages/observability con ports & adapters, compiladoB · seguir con lib/analytics/ por app y AnalyticsService aparte |
A | Principio del dominio: «instrument shared libraries, never per application». El admin entra gratis y client y consultorio migran sin cambiar componentes: los hooks mantienen su firma. |
| Gating del error tracking | A · mantener solo producción B · abrirlo a dev |
A · con env | Decisión previa consciente por el ruteo a Slack; se conserva con deployment.environment explícito. Los logs OTLP sí salen en dev. |
| Session replay | A · activarlo con masking estricto B · dejarlo apagado |
A | Sin replay el error no tiene contexto; con PHI en pantalla, el enmascarado total es la única opción para el consultorio. Cierra IN-015. |
| PostHog en admin | A · portarlo desde el paquete compartido B · copiar lib/analytics/ del client |
A | Es el primer consumidor limpio del paquete: valida que sirve sin bypasses antes de migrar las apps que ya funcionan. |
| Shipping de logs | A · stdout de Render + pino-opentelemetry-transport → PostHog LogsB · solo stdout |
A | «Volume at the source, retention at the store». El segundo destino va gated por la presencia de OTEL_EXPORTER_OTLP_LOGS_ENDPOINT. |
| Nombre del interceptor | AuditTrailInterceptor + decorador @AuditTrail |
— | AIAuditInterceptor ya ocupa la palabra «Audit» y está documentado como engañoso en CHECKLISTS.md. Dos cosas distintas no comparten nombre. |
Las fases no se agrupan por prioridad sino por superficie de verificación: cada una cierra
cuando un comando concreto pasa, no cuando alguien dice que está lista. La dependencia real es la
que manda — casi todo cuelga del request_id, así que la fase 2 es el cuello de botella.
El paquete @workspace/observability
Sin dependenciasPuertos, adaptadores, factories y scrub; tsconfig y build; Dockerfile de
la API con los dos stages y el dist; transpilePackages más el alias en
las tres Next apps; ObservabilityModule en la API con AnalyticsService
convertido en fachada.
npm run build -w packages/observability pasa;
turbo typecheck verde en la API y las 3 apps; docker build apps/api
pasa; los specs del scrub y de las factories en verde; AnalyticsService.spec sigue
verde.
Logs canónicos + request id
Depende de · 1Dependencias de pino, logging.config.ts, request-context.ts
y su middleware, LoggerModule.forRootAsync, main.ts con
bufferLogs y useLogger, headers de CORS,
ApiErrorBody.requestId, retiro del DemoRequestLoggerInterceptor,
variables de entorno y LOG_LEVEL en render.yaml.
curl -i /health devuelve x-request-id; una
request real emite una línea JSON con request_id, userId,
statusCode y responseTime; logging.config.spec,
request-context.middleware.spec y posthog-session-cors.spec verdes; con
el endpoint OTLP seteado la línea aparece en PostHog Logs desde dev.
Captura de errores en la API
Depende de · 2El filtro con request_id y $session_id más el saneo del
mensaje 5xx; TrackedCronService en los cuatro crons; los handlers
process.on; flush en el shutdown.
extra.$session_id; los cuatro
specs de cron prueban runTracked; un throw forzado en un endpoint de
dev aparece en PostHog con su request_id.
Las webs sobre el paquete
Depende de · 1, 2Admin completo (provider, /ingest, identidad, env);
global-error.tsx en las tres; ObservabilityProvider y hooks;
lib/analytics/track.ts reescrito sobre el puerto; replay con el masking de cada app;
header x-posthog-session-id en los seis clientes de ky;
instrumentation.ts; check-analytics promovido a la raíz.
turbo build de las 3 apps pasa (con los dev servers
apagados); check-analytics verde en las tres; en el navegador una request lleva
X-POSTHOG-SESSION-ID y la API la loguea como sessionId; un
throw en el error.tsx del admin llega a PostHog; el replay del
consultorio muestra el texto enmascarado.
El audit trail como red de seguridad
Depende de · 2Migraciones del schema audit, triggers y enable_tracking
sobre las tablas con PHI; auditedTransaction; columna
audit_logs.requestId; @AuditTrail y su interceptor;
GET /audit/:table/:recordId; audit-schema-guard.spec y
audited-transaction.spec; docs de CB-007, IN-007 e IN-015.
prisma migrate deploy pasa en local; un
updateMany sobre appointments deja fila en record_version
con changed_columns y sin valores; un PATCH decorado deja
audit_logs con el mismo requestId que el header de respuesta;
GET /audit/appointments/:id devuelve las dos fuentes;
grep -c "old_record" migrations = 0.
Alertas, docs y el gate final
Depende de · 3, 4, 5Alerta de PostHog a Slack con las llaves; sección de Observability en
API_PATTERNS.md y CLAUDE.md; README del paquete;
docs/analytics/README.md actualizado a tres apps; borrar
posthog-setup-report.md; smoke script
scripts/smoke-test-observability.ts.
test_id y el evento, la
excepción y el log aparecen en PostHog con ese id; una alerta de prueba llega a Slack con su
request_id; las docs revisadas.
Un proyecto de infraestructura crece solo si no se le pone borde. Estas cinco cosas no entran, y dos de ellas son fases posteriores del mismo dominio — no descartes.
Logger a otra API. Se cambia el transporte, no la interfaz.Una request deja una línea, un error con su replay y una fila que nadie puede borrar.
Los tres las escriben distintos sistemas, con retenciones distintas y lectores distintos — y las tres
caben en la misma búsqueda porque comparten un request_id. Eso es todo el proyecto: no
un panel nuevo, sino una llave que hoy no existe. Los principios del dominio, y el que es de Amedi:
Los principios que ordenan cada decisión de esta página
- 01
Una línea canónica por request. No veinte líneas sueltas que hay que reconstruir: un objeto con todo lo que se sabe al terminar.
- 02
Instrumenta librerías compartidas, nunca aplicación por aplicación. Un paquete, cuatro consumidores, cero copias.
- 03
Propaga el contexto. El id viaja en el AsyncLocalStorage, no como parámetro por la capa de dominio.
- 04
Librerías estándar, wrappers finos, agregadores hospedados. pino, OpenTelemetry y PostHog — nada artesanal que haya que mantener.
- 05
El volumen se corta en la fuente; la retención se decide en el almacén. Lo que no se emite no cuesta.
- 06
Llaves de correlación planas.
request_iden el nivel de arriba, noreq.id.value: se filtra igual en cualquier herramienta. - 07
Alertas autocontenidas. La que llega a Slack trae
request_id,$session_idy el issue: se actúa sin abrir tres pestañas. - 08
Cero PHI cruda en telemetría. El de Amedi. Logs redactados, mensajes saneados, auditoría con nombres de columna y diff redactado — en los tres planos, sin excepción.
Un request_id. Header entrante o UUID, ALS, y eco en la respuesta.
La fase 2. Sin el hilo, las otras cinco escriben datos que no se pueden cruzar.
Cero PHI cruda. Es lo que descarta la opción B de auditoría, sin discusión.
Las seis verificaciones. Cada fase cierra con un comando, no con una opinión.