infraestructura · v1.0 · ago 2026

Observability · infraestructura

Saber que algo se rompió antes de que lo diga el paciente.

Amedi nace con logs, errores y auditoría unidos por un solo request_id: una línea por request, el error con su replay, y la fila de auditoría con su actor — sin PHI cruda en ningún sitio. No es un panel nuevo: es el hilo que hoy no existe, cosido a lo que ya funciona — el PostHog de la API, el trail app-layer con redacción y el patrón ALS que el monorepo ya tiene consolidado.

Apps
api · client · consultorio · admin
Mecanismo
Un request_id
Planos
logs · errores · auditoría
Riesgo
Cero PHI cruda
01Punto de partida
auditoría · 23 ago 2026

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.

Ya existe · se conserva

Lo que no se toca

  • PostHog en la API — AnalyticsService y PosthogExceptionFilter de 5xx vía APP_FILTER, con gating a producción.
  • PostHog en client y consultorio — lib/analytics/: PHProvider, puente de identidad, error.tsx → reportError, rewrites /ingest y el guard check-analytics.ts.
  • Audit trail app-layer completo — audit-prisma.extension.ts + audit-context + audit-buffer + redaction.ts fail-closed (keep / hash / <redacted>) sobre 11 modelos con PHI → audit_logs.redactedDiff.
  • PatientAccessLog (lecturas) y PermissionAuditLog (roles).
  • El patrón ALS ya consolidado — demo-context, audit-context y audit-buffer, todos con run().
  • multiSchema activo en Prisma y un precedente vivo de schema fuera de Prisma: demo.
Falta · los ocho huecos

Lo que hay que coser

  1. Logs. 65 usos del Logger de Nest y 7 console.*; sin pino, sin JSON, sin request_id, sin OTLP. main.ts no tiene bufferLogs ni useLogger.
  2. Request id. Inexistente. Peor: allowedHeaders del CORS es una allow-list explícita que bloquea x-request-id y x-posthog-session-id.
  3. Errores de la API. Cuatro crons se tragan el error en el logger; no hay unhandledRejection ni uncaughtException; el filtro no adjunta id ni sesión.
  4. Web. El admin tiene cero PostHog; ningún global-error.tsx en client ni consultorio; el session replay sin configurar en código (IN-015: riesgo de PHI, masking pendiente).
  5. Join sesión ↔ request. Nadie envía x-posthog-session-id; el beforeRequest de ky es el punto de inyección en las tres apps.
  6. Auditoría. Puntos ciegos documentados: createMany/updateMany/deleteMany, SQL directo, seeds y ~100 modelos sin instrumentar. IN-007 sigue abierto y AIAuditInterceptor es solo un logger.debug con nombre engañoso.
  7. Paquete compartido. No existe @workspace/observability; la lógica de PostHog está duplicada en client y consultorio, y la API tiene la suya.
  8. Docs. API_PATTERNS.md y CLAUDE.md sin una línea de logging; CB-007 marcado abierto pero implementado; posthog-setup-report.md obsoleto.
02Los tres planos
logs · errores · auditoría

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

Entra POST /appointments · doctor autenticado · app consultorio una sola request atraviesa guards, servicios y Prisma x-request-id: 8f3c1d92…a91e
request_id · la única llave compartida
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_id para 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.

03El hilo: un request_id
middleware + ALS

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

01 · entra Header o nada

x-request-id del cliente, o randomUUID() si no viene. El CORS abre allowedHeaders a x-request-id y x-posthog-session-id.

02 · genera pino-http · genReqId

Normaliza (≤128) y lo deja en req.id. Mismo contrato de orden que en mogos: pino genera, el middleware adopta.

03 · antes de los guards RequestContextMiddleware

Lee x-posthog-session-id (≤64, sin fallback) y abre runWithRequestContext({ requestId, sessionId }, next).

04 · viaja solo guards · servicios · prisma

Todo el árbol lee getCurrentRequestId() desde el ALS. Cero prop drilling de contexto por la capa de dominio.

los cuatro consumidores del id
Logs Línea canónica

Una por request, con request_id, sessionId, userId e is_demo.

Errores PosthogExceptionFilter

tags.request_id y extra.$session_id — el $session_id es lo que engancha el replay.

Auditoría · app audit_logs.requestId

Columna nueva, la escribe el $extends que ya existe.

Auditoría · base de datos record_version.request_id

El trigger lo lee de set_config('app.request_id', …, true).

Sale El id vuelve al cliente header x-request-id en la respuesta (vía exposedHeaders) y ApiErrorBody.requestId en el cuerpo del error

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.

La línea canónica · una request completada, un solo objeto JSON
// 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.

Orden, que es lo que se rompe

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.

04El paquete compartido
@workspace/observability

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

Código de producto AnalyticsService · useErrorTracker · useAnalytics

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.

Puerto ErrorTrackerPort · AnalyticsPort

Interfaces en ports/. Es la única superficie que el producto conoce; los adaptadores son intercambiables sin tocar un solo componente.

la factory elige · create-node · create-browser
Entorno de test NoOp

Todo devuelve void. Los tests no dependen de red ni de una key.

Dev sin key Console

Imprime el evento con formato. El desarrollador ve lo que se habría enviado.

Con key PostHog

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.

packages/observability/src/compilado con tsc a CommonJS, como ai-knowledge
index.tssuperficie pública única
ports/ error-tracker.port.ts · analytics.port.tslo que el producto conoce
types/contratos compartidos
utils/ scrub.tspatrones de mogos + PHI de Amedi
factory/ create-node-observability.tsAPI (NestJS)
factory/ create-browser-observability.tsclient · consultorio · admin
adapters/ posthog-node/ · posthog-browser/los que hablan con PostHog
adapters/ noop/ · console/test y dev sin key

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.

Cómo se compila

Sin sorpresas en el deploy

  • tsc a CommonJS, con el mismo tsconfig y el mismo build que ai-knowledge — el precedente que ya funciona en el monorepo.
  • Dockerfile de la API: COPY package.json en los dos stages, build del paquete y COPY del dist.
  • Next apps: transpilePackages más un alias posthog-node: false en el bundle de cliente, para que el adaptador de servidor no viaje al navegador.
Cómo se consume

Un módulo, tres providers

  • API: ObservabilityModule con @Global() provee ERROR_TRACKER y ANALYTICS.
  • Webs: ObservabilityProvider sobre la factory de navegador y dos hooks, useErrorTracker y useAnalytics.
  • lib/analytics/track.ts se reescribe encima del puerto sin cambiar su firma: cero migraciones en las pantallas.
05Pipeline de logs
nestjs-pino · dos destinos

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

Origen · intacto 65 × Logger de Nest

Más los 7 console.* que se migran al Logger. Ni una firma cambia.

Transporte nestjs-pino · LoggerModule.forRootAsync

quietReqLogger, customAttributeKeys.reqId = 'request_id', customProps con userId, actingAsDoctor, sessionId e isDemo.

buildTransportTargets() · los dos a la vez
Siempre pino/file → fd 1 → Render

En dev, pino-pretty. La consola del servicio nunca se queda muda, aunque no haya endpoint OTLP configurado.

Si hay endpoint pino-opentelemetry-transport → PostHog Logs

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.

logging.config.ts

Puro, y por eso testeable

  • resolveLogLevel() — de LOG_LEVEL y 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_HEADER y POSTHOG_SESSION_ID_HEADER, que el CORS de main.ts importa en vez de repetir el string.
Lo que nunca sale

El redact, escrito una vez

  • Headers: authorization, cookie, set-cookie.
  • Body: los campos con PHI, con la misma lista que ya usa redaction.ts en 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.
06Captura de errores
4 superficies · 6 mecanismos

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

AppConfiguración de replayPor 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.
Cierra IN-015

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ó.

Gating, sin cambios

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.

07Auditoría como red de seguridad
app layer + triggers

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.

El otro camino · descartado

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

La transacción PrismaService.auditedTransaction(actor, fn) set_config('app.user_id' | 'app.actor_label' | 'app.request_id', …, true) — el tercer argumento true (is_local) es obligatorio: pgbouncer corre en transaction-mode y un GUC de sesión se filtraría a la request de otro.
el mismo request_id baja a las dos capas
Capa de aplicación · la intención @AuditTrail → AuditTrailInterceptor → audit_logs

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.

Capa de base de datos · el hecho trigger → audit.record_version

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.

La lectura GET /audit/:table/:recordId · solo ADMIN Allowlist AUDITED_TABLES y bind con Prisma.sql. Devuelve record_version y audit_logs del mismo record_id, ordenados por ts: el hecho y la intención en una sola línea de tiempo.

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.

Deuda que se cierra sola

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.

08Decisiones
nueve bifurcaciones

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.

TemaOpcionesDecisiónPor qué
Audit trail A · triggers sin row images (metadatos + changed_columns[])
B · triggers con to_jsonb(OLD/NEW), tipo mogos
C · 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 intactos
B · 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, compilado
B · 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 Logs
B · 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.
09Seis fases
agrupadas por superficie de verificación

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.

1
El paquete @workspace/observability
Sin dependencias

Puertos, 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.

Cierra cuando: 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.
2
Logs canónicos + request id
Depende de · 1

Dependencias 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.

Cierra cuando: 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.
3
Captura de errores en la API
Depende de · 2

El 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.

Cierra cuando: el spec del filtro prueba 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.
4
Las webs sobre el paquete
Depende de · 1, 2

Admin 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.

Cierra cuando: 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.
5
El audit trail como red de seguridad
Depende de · 2

Migraciones 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.

Cierra cuando: 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.
6
Alertas, docs y el gate final
Depende de · 3, 4, 5

Alerta 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.

Cierra cuando: el smoke script imprime un 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.
10Fuera de alcance
escrito para no discutirlo después

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.

Sentry y apps móviles. Amedi no tiene Expo; PostHog cubre las cuatro superficies que sí existen.
Migrar los 65 call sites del Logger a otra API. Se cambia el transporte, no la interfaz.
pgaudit (IN-007). Superseded por los triggers de la sección 07.
El pipeline de agentes (alerta → issue → PR). Fase posterior del dominio, no de este proyecto.
Métricas y APM (traces OTLP). Por ahora solo logs: un plano a la vez.

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_id en el nivel de arriba, no req.id.value: se filtra igual en cualquier herramienta.

  • 07

    Alertas autocontenidas. La que llega a Slack trae request_id, $session_id y 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.

El mecanismo

Un request_id. Header entrante o UUID, ALS, y eco en la respuesta.

Mayor efecto

La fase 2. Sin el hilo, las otras cinco escriben datos que no se pueden cruzar.

El límite

Cero PHI cruda. Es lo que descarta la opción B de auditoría, sin discusión.

El contrato

Las seis verificaciones. Cada fase cierra con un comando, no con una opinión.

Amedi · observability · propuesta v1.0 Agosto 2026 · api + client + consultorio + admin · auditado en el monorepo