dec-0135

Vista generada de dec-0135: Telemetría correlacionada de extremo a extremo: TTFF desde la intención y una traza del clic al daemon

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0135-telemetria-correlacionada-e2e.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0135-telemetria-correlacionada-e2e.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoPROPOSED
Fecha2026-10-04
Ficherodocs/decisions/dec-0135-telemetria-correlacionada-e2e.md

Por qué importa (del frontmatter del ADR):

Fija qué mide el TTFF (desde la intención de reproducir, no desde que se abre el stage) y cómo una sola traza W3C sigue una reproducción del clic del navegador al daemon Zig pasando por el BFF, los servicios HTTP, el bus spire por NATS y el IPC unix, con logs (better-logger y el logger del daemon) y métricas que llevan los mismos ids. Sin este ADR, cada salto inventaría su correlación (hoy X-Request-ID que nadie lee, un traceparent que se pierde en tres sitios, logs del daemon sin ids) y el TTFF seguiría midiendo cuánto tarda la persona en pulsar play.

Nodos del roadmap que lo citan en refs: ninguno.

Páginas de la documentación que lo citan: Seguir una reproducción por todos los servicios (especificado)

Texto del ADR

Leído de docs/decisions/dec-0135-telemetria-correlacionada-e2e.md, el fichero canónico.

dec-0135 — Telemetría correlacionada de extremo a extremo: TTFF desde la intención y una traza del clic al daemon

  • Fecha: 2026-10-04
  • Estado: PROPOSED. Concreta la decisión de waxin del 2026-10-04: «TTFF real, desde la intención de reproducir (play), no desde que tardas en pulsarlo; y todas las métricas BIEN con OTel, con better-logger, traces, spans, correlacionado todo, para poder medir bien, que son muchos servicios y mirar logs será difícil si no». Lo que falta ratificar son las preguntas abiertas (P1–P5); lo que cabe dentro de los locks existentes se puede implementar ya.
  • Nodo: track/observability (gate items OB1 y OB2; milestone playback-metrics, ticket track/observability#02; contrato de redacción M4 de track/observability#01).
  • Relacionados: r20 §2.7 (LOCKED: F2 = cableado collector/exporter, sampling y propagación de traza e2e Bun↔NATS↔Zig), dec-0004 (OTel transversal, sin observability-svc), dec-0116 (contract-first), dec-0117 A8 (telemetría sin tokens, paths ni ids de actor en claro), dec-0118 (security.* por OTel), dec-0119/dec-0120 (sobre spire con traceparent firmado), dec-0123 (service.version, styx.train), dec-0134 (origen TCP del daemon), r01.

Contexto

Auditoría de solo lectura sobre fd550eb (2026-10-04). Lo que hay hoy:

  • El TTFF empieza donde no toca. startedAt se toma al montar usePlaybackSession (PlayerStage.hook.ts), y ningún reproductor hace autoplay, así que el TTFF incluye lo que tarda la persona en pulsar play (playback-metrics.ts lo documenta). El harness lo oculta porque llama a play() él mismo. No hay fases.
  • La traza se corta en tres sitios: navegador → BFF (no hay traceparent), BFF → servicios (el web no tiene SDK; el docstring de apps/web/src/server/eden.ts dice que reenvía traceparent y no lo hace; sólo manda un X-Request-ID que ningún servicio lee) y playback-svc → daemon (packages/bus/src/daemon.ts no pasa traceparent al UnixClient). Funciona servicio → NATS → servicio.
  • El daemon no tiene trazas. Sus logs salen a stderr sin estructura ni ids, y su exportador de métricas sólo habla con 127.0.0.1, sin token, y nadie lo configura en compose.
  • Logs: los ids de traza sólo se añaden en el transporte OTLP, no en stdout. El recurso de los logs no es el de las trazas. El web usa better-logger 0.18 y queda fuera de @styx/observability.
  • Métricas: sólo playback-svc tiene MeterReader. En los otros cinco servicios, el histograma HTTP del plugin es un no-op.
  • Redacción: OtlpLogTransport exporta cada campo del record sin filtrar, e identity.actorId va en claro en un span. Ambas cosas chocan con A8.

El sobre spire v2 ya lleva traceparent dentro de la cabecera firmada (flag b2, 55 bytes, validado). El hueco no está en el formato de spire.

Decisiones

D1 — TTFF y seek se miden desde la intención

  • Origen t0 = la marca de tiempo (event.timeStamp, misma base que performance.now()) del gesto que expresa «reproduce ahora»:

    • cta: el clic en «Reproducir» cuando el stage arranca la reproducción por su cuenta, porque el clic ya lo pidió;
    • gesture: el play del control del reproductor cuando el stage no lo hace.

    Si el play() del stage se rechaza (NotAllowedError), ese intento se descarta y el siguiente gesto de play es el nuevo t0.

  • Fin t1 = el primer callback de requestVideoFrameCallback con presentedFrames ≥ 1 y mediaTime avanzado después de t0. Se usa expectedDisplayTime si existe. Sin rVFC, el fin es playing seguido de un timeupdate con currentTime mayor que el de partida.

  • Excluido: la duda de la persona, porque t0 es el gesto y no el montaje. El trabajo hecho antes de t0 (precarga, sesión abierta de antemano) se marca prewarmed y sus fases cuentan 0.

  • Abandono: si hay una pausa, la pestaña se oculta, se cierra o hay un error fatal entre t0 y t1, no se emite TTFF. Se emite un evento startup_abandoned con una razón de vocabulario cerrado.

  • Fases (duraciones desde t0, opcionales y acotadas):

    • session: hasta que el descriptor está abierto;
    • manifest: playlist o metadatos (loadedmetadata en el byte-range directo);
    • segment: primer segmento o canplay;
    • frame: primer frame.
  • Seek: t0 = el último seeking de una cadena. Los seeks a menos de 250 ms se agrupan, y la medida se reinicia con el último destino. t1 = el primer frame de rVFC con mediaTime en el destino, con tolerancia por el ajuste a keyframe. También vale en pausa.

  • Contrato (dec-0116): PlaybackMetricEvent en @styx/api-contracts amplía la variante ttff con origin: 'cta' | 'gesture', prewarmed: boolean y phases (enteros acotados), y añade la variante startup_abandoned. Sin texto libre ni ids. Las métricas siguen sin ids como etiqueta.

D2 — Una traza por intención; W3C de punta a punta

  • Modelo:
    • Cada intención (arranque, seek) es una traza.
    • La traza del arranque cubre desde el gesto hasta el primer frame: BFF, playback-svc, bus, daemon y el lote de métricas que lleva el TTFF.
    • Las trazas de una misma sesión se juntan por el atributo styx.playback.session_id, que va en todos los spans y logs. Los spans posteriores (lotes de métricas) llevan un link al contexto del arranque, que playback-svc guarda con la sesión.
  • Navegador:
    • Genera traceparent (v00, ids con crypto.getRandomValues) en t0 y lo manda en cada llamada a una server fn, mediante un middleware de función global de TanStack Start del lado cliente.
    • Fase 1: el navegador no exporta spans. El span raíz web.playback.intent queda implícito, y sus tiempos llegan como fases del TTFF.
    • Fase 2 (P2): un exportador mínimo propio (OTLP/JSON, sin SDK) envía unos pocos spans al BFF por /_otel/v1/traces en el mismo origen. El BFF los autentica con la sesión, los acota en tamaño y frecuencia, filtra nombres y atributos contra una lista blanca y les añade el token.
  • BFF (SSR del web):
    • Arranca el SDK con @styx/observability.
    • Trata el traceparent del navegador como no confiable: valida el formato estricto y lo descarta si no es válido. Ignora tracestate y baggage. El muestreo es ParentBased(remoteParentSampled = TraceIdRatio(r)).
    • Abre un span por server fn e inyecta traceparent en Eden.
    • X-Request-ID se borra: el id de correlación es el trace id (DELETE > @deprecated, 0 lectores).
  • Servicios: el plugin HTTP ya extrae el contexto. NATS ya lo inyecta y extrae. Lo que se añade:
    • IPC al daemon: daemon.ts crea un span cliente daemon.<contract> y pasa traceparent al UnixClient.
    • Eventos: el contrato de bus declara traceMode: 'child' | 'link'. Un evt que es consecuencia directa de una petición (seguridad, alta de ingesta) es hijo de esa petición. Un evt periódico (transportSignals) abre una raíz nueva con link, para no alargar una traza durante horas.
  • Respuestas spire: hoy copian el traceparent de la petición (router.zig, router.ts). Pasan a llevar el contexto del span del handler (si el handler no da ninguno, se mantiene el eco). Es un cambio de semántica, no de formato, y va al repo spire como PR más re-pin.

D3 — El traceparent en spire sigue dentro de la cabecera firmada; sin tracestate ni baggage en el bus

  1. Ya está ahí en v2. Sacarlo a una cabecera aparte sin firmar sería un formato nuevo (v3), con vectores y fuzz diferencial nuevos, y no se gana nada.
  2. La firma no estorba a la propagación. El contexto de traza es por salto: quien envía pone su propio span cliente y firma con su clave. Nadie reescribe un sobre recibido, y NATS no muta payloads.
  3. La caché de respuestas y el anti-replay se atan a campos firmados (RequestKey.of incluye el traceparent). Una cabecera sin firmar abriría peticiones «iguales» con trazas distintas.
  4. No hace falta confidencialidad: son ids aleatorios, sin PII.
  5. Sin tracestate (Styx no tiene estado de vendor) ni baggage, que es un vector de fuga de PII. Si alguna vez hacen falta, irán en v3 con un tope de tamaño. Tampoco se ponen cabeceras NATS traceparent: el sobre es la única fuente.

D4 — El daemon continúa la traza

  • IPC: cada petición recibida por spire unix abre un span de servidor daemon.<contract>. Su padre es el traceparent del sobre y su span id es aleatorio, de 64 bits. Los helpers de parse, hijo y format viven en spire-zig (spire.trace), que es dueño de la validación del campo. No se reimplementan en styx.
  • Sesión: openSession guarda el contexto del span con la sesión. Los bytes servidos a esa sesión se atribuyen a esa traza por la sesión de la SCT, sin cabecera nueva en el navegador ni preflight CORS nuevo. Por petición de datos sólo se abren spans en la ventana de arranque (las primeras 8 peticiones) y en la primera petición tras un salto de rango. El resto se agrega.
  • Contadores por sesión (bytes, peticiones, aciertos y fallos de caché, saltos, espera a la fuente): son atributos del span daemon.session al cerrarse y un log session.summary. Nunca etiquetas.
  • Exportación: OTLP/HTTP JSON a /v1/traces y /v1/logs desde un hilo propio con una cola acotada; si la cola se llena, se descarta y se cuenta, y nunca bloquea los hilos del bucle. Implementado (lane daemon-otlp, 2026-10-06; native/zig/media-daemon/otel/otlp_*.zig):
    • JSON y no protobuf. El daemon ya habla JSON al collector (métricas), el collector y el receptor de trace-proof lo decodifican, el cuerpo es pequeño (unos registros por petición) y no hay una librería protobuf en el árbol que usar, ni conviene escribir otra. Los ids van en hex y los enteros de 64 bits como string, como pide OTLP/JSON.
    • Una sola fuente. log_limit.logFn entrega al exportador cada línea que escribe (la del stderr, con el rate limit por call site delante). Una línea del scope span es un span (daemon.<contrato>, servidor, hijo del span del cliente); el resto, un LogRecord con trace_id/span_id de primer nivel. Los registros JSON actuales y los exportados no pueden divergir. El scope otlp (los fallos del propio exportador) no se exporta.
    • Spans. Uno por petición IPC; daemon.session (hijo del span que abrió la sesión, con los contadores de session.summary como atributos); y los de servir de /styx/pkg/ (daemon.serveSegment, servePlaylist, serveInit, hijos del span de openPackaging, con los bytes enviados) sólo en la ventana de arranque (8 peticiones) y en la primera tras un salto de segmento; el resto se cuenta en el resumen. El span de servir cubre desde la admisión hasta que el trabajo se libera (la cola, la producción off-loop y el envío).
    • La cola nunca bloquea. emit copia un registro de tamaño fijo a un zkit.BoundedQueue (.reject): un mutex corto, sin red, sin alloc y sin fallo. Cola llena = dropped_full; el exportador lo anuncia con un LogRecord (otlp export dropped N records…) en cuanto llega al collector. Un collector caído o lento sólo cuesta latencia del exportador: un POST tiene un plazo (OTEL_EXPORTER_OTLP_TIMEOUT, 10 s por defecto) y el endpoint que falla entra en backoff exponencial acotado (0,5 s a 30 s) por señal; en el backoff sus registros se descartan y se cuentan, no se guardan. Al cerrar, exporta lo que queda dentro de 2 s y descarta el resto.
    • Memoria acotada. Todo lo que posee el exportador (anillo de 1 024 registros por defecto, un lote, un cuerpo de 256 KiB) se reserva una vez, en create, bajo un zkit.safety.BudgetAllocator cuyo límite sale de la configuración; el hilo no reserva nada después. El código usa las primitivas de zkit (BoundedQueue, BudgetAllocator, time, os); no hubo que reimplementar nada (dec-0103). Lo único genérico que se echa de menos es un cliente HTTP con plazo, que hoy es otlp_http.zig (sólo HTTP/1.1 sin TLS, poll y getaddrinfo) y que sería candidato a zkit si un segundo consumidor lo pide.
    • Configuración = las OTEL_* estándar que ya usan los servicios: OTEL_EXPORTER_OTLP_ENDPOINT (+ _TRACES_ENDPOINT / _LOGS_ENDPOINT), OTEL_SERVICE_NAME, OTEL_BSP_MAX_QUEUE_SIZE / _MAX_EXPORT_BATCH_SIZE / _SCHEDULE_DELAY, OTEL_EXPORTER_OTLP_TIMEOUT, OTEL_SDK_DISABLED. Sin endpoint, el daemon no exporta (ni hilo ni cola). El endpoint es el receptor OTLP/HTTP del collector (4318), no el gRPC (4317) de los servicios, y http:// (el daemon no lleva cliente TLS: el collector va dentro de la red de despliegue). El Bearer sale de OTEL_INGEST_TOKEN_FILE (secreto de fichero); OTEL_INGEST_TOKEN en el entorno se rechaza y deja la exportación apagada. Compose los pone en media-daemon.
    • Redacción en origen (D7). Las claves de atributo son comptime y una clave prohibida es @compileError (otlp_redact.assertKey, mismo vocabulario que redaction.ts). El único texto libre que sale, el mensaje de un log, pasa por otlp_redact.sanitize en el codificador (Bearer, JWT, styx_*, emails, queries, sct=/token=, rutas absolutas, file://, unidades de Windows y blobs base64url largos). check:telemetry-redaction ya lee los argumentos de cada log de Zig; el e2e daemon-run.sh añade canarios con control positivo (el canario SÍ está en el stderr del daemon y NO en lo recibido).
    • Qué logs salen. Sólo info, warn y error. Los debug/trace quedan en stderr/JSON y no se exportan: los escribe el trabajo fuera del bucle (p. ej. una línea por lectura de fichero) sin el contexto de la traza, y mandarlos fuera de la máquina daría registros sin trace_id dentro de los spans de servir (logs.correlated-in-hop lo detectó en el e2e real con collector). Los mensajes exportados además ocultan la relPath de un root://<rootId>/<relPath>, las rutas absolutas (también pegadas a una etiqueta o tras |, @, =) y las credenciales con forma de JWT/SCT o styx_* pegadas a una etiqueta (sct:eyJ…); también file:// y rutas de Windows pegadas a una etiqueta o separador. Eso sólo oculta la relPath en la forma root://, y la garantía del daemon es esta y no más: native/zig/tools/log_path_audit.zig (en zig build test, y zig build test:log-path) recorre media-daemon/, media-core/, transmux/ y libav-bridge/ y falla toda llamada a log.info/warn/err/debug cuyos argumentos nombren un valor con forma de ruta (relPath, rel_path, path, uri, *_path, *Path, *_uri) y cuyo formato no escriba root://, salvo las de una lista de excepciones justificadas (ruta de CONNECT ya redactada por redactPath, etiqueta root:// de la sesión, y configuración del operador: socket, cert/key, directorio de una raíz, URLs del colector y de NATS). AssetRef imprime root://<rootId>/<relPath> con {f} y LocalFileSource loguea desde su AssetRef, nunca desde la etiqueta que le pasa el llamante. No cubre: un valor con ruta cuyo nombre no parezca de ruta, un logger cuyo receptor no contenga log, ni las rutas de configuración del operador (se escriben tal cual; el sanitizador sólo oculta las absolutas bajo las raíces conocidas de PATH_ROOTS, file:// y unidades de Windows). Una relPath con espacios queda cubierta sólo hasta el primer espacio.
    • Muestreo. Sin cambios: sólo se exporta span de una traza con sampled=1; los logs llevan el trace_id aunque no lo estén.
  • Eventos: los evt que publica el daemon pasan traceparent según el traceMode de su contrato.
  • Muestreo: el daemon respeta el flag sampled. Si es 0, no registra spans, pero los logs siguen llevando trace_id.

D5 — Logs: un formato, los mismos campos, exportados por OTLP

  • Campos obligatorios en cada record, TS y Zig:

    • trace_id y span_id: en OTLP, en los campos de primer nivel del LogRecord, no como atributos;
    • severity, service.name, service.version, service.namespace=styx y styx.train: en el mismo Resource que las trazas y las métricas;
    • los ids de dominio en contexto: styx.playback.session_id, styx.work.id y styx.request.contract.
  • TS: @styx/observability da un solo initTelemetry, que monta los providers de trazas, métricas y logs con un Resource compartido, OTLP/gRPC con bearer en los tres y redacción (D7). Las seis copias de service/otel/telemetry.ts se borran.

    • El logger better-logger 5.x lleva un transform que estampa el contexto activo y un contexto de dominio por AsyncLocalStorage (withLogContext({ sessionId }, fn)).
    • Dos salidas: OTLP logs (SDK oficial @opentelemetry/sdk-logs, en lugar de OtlpLogTransport) y stdout JSON lines con los mismos campos, para docker logs y grep por trace id.
    • El web sube a better-logger 5.x (P5).
  • Exportación: OTLP push. El collector sólo tiene el receiver OTLP. Leer los logs de Docker exigiría montar el socket de Docker en el collector, y eso no se hace. El stdout no se scrapea: es para depurar a mano.

  • Zig: log_limit.logFn escribe una línea JSON por record:

    • ts en RFC 3339 con ns, level, scope y msg (el texto formateado, escapado);
    • service.name, trace_id, span_id y styx.playback.session_id, sacados de un contexto thread-local que el handler fija y limpia con defer, y que el bucle fija por callback;
    • dropped cuando corresponde.

    El rate-limit por call site se mantiene. La misma línea va a la cola OTLP de logs.

D6 — Métricas: semconv donde exista, styx.* donde no, etiquetas cerradas y registradas

  • Registro: un registro único en @styx/observability lista el nombre, el instrumento, la unidad y las claves y valores de etiqueta permitidos, y genera un JSON. Un test comprueba que los atributos registrados son un subconjunto del registro. El exportador del daemon se comprueba contra el mismo JSON.
  • Etiquetas: el http.route es una plantilla, y el destino de bus es el nombre del contrato, nunca el subject crudo. Ningún id es etiqueta; los ids van en spans y logs (y en exemplars donde el SDK los soporte; no se depende de ellos).
  • Cada servicio (los seis, más el BFF):
    • http.server.request.duration;
    • messaging.process.duration y messaging.client.operation.duration, con el estado spire como etiqueta;
    • el retardo del event loop.
  • playback-svc:
    • lo que ya tiene;
    • origin en styx.playback.ttff;
    • styx.playback.startup.phase.duration{phase} (cada fase como tramo propio: la del contrato es acumulada y el registro graba la diferencia con la anterior, de modo que los cuatro tramos suman el TTFF), styx.playback.startup.abandoned{reason}, styx.playback.session.open.duration y styx.playback.sessions.active;
    • styx.daemon.ipc.duration{contract,status};
    • styx.playback.ingest.poll.iterations{outcome} (enmienda de muestreo de D8).
  • BFF: styx.web.server_fn.duration{fn,outcome} y styx.web.catalog.fallbacks, que sustituye al contador en memoria. (Registrado el 2026-10-06 con fn como styx.bff.handler ∈ router|serverFn|other, porque la ruta o la función concreta lleva ids; outcome ∈ ok|client_error|server_error|error; el fallback lleva styx.fallback.reason, el código de Eden o other.)
  • Daemon:
    • los acumulados pasan a ser Sum monótonos (hoy son gauges);
    • histogramas styx.daemon.request.duration{delivery,kind,outcome}, styx.daemon.ttfb{delivery} y styx.daemon.ipc.handle.duration{contract,status};
    • styx.daemon.cache.lookups{tier,result}.

D7 — Redacción: en origen y en el collector, con guard

  • Prohibido en spans, logs y etiquetas, en claro: SCT, cookies, cabeceras Authorization, tokens de invitación o de quick connect, API keys, secretos, emails, ids de actor y paths absolutos (dec-0117 A8).
  • Ids de actor: se seudonimizan. El atributo styx.actor.pseudo_id es HMAC-SHA256 con una clave de telemetría que sale de un secreto de fichero, truncado a 16 hex (P4).
  • Paths: se registran como rootId más la relPath oculta (root://<rootId>/[path]). Se oculta, no se hashea: el hash para correlar sigue sin implementar.
  • URLs: van sin query.
  • El id de sesión de playback se permite porque no es portador: abrir o leer la sesión exige la cookie y la propiedad (SW11). Si algún día lo fuera, se trataría como una SCT.
  • Defensas en origen:
    • TS: un wrapper de exporter de spans y un procesador de logs que borran las claves prohibidas y reemplazan los valores con forma de credencial (JWT, Bearer, SCT o email) por [redacted].
    • Zig: los nombres de campo son comptime, y una clave prohibida es @compileError.
  • Defensa en profundidad: un transform OTTL en el collector con las mismas reglas.
  • Guards:
    • check:telemetry-redaction, estático: claves prohibidas en setAttribute, en atributos de span o log, y headersToSpanAttributes/recordBody en el plugin HTTP;
    • tests con valores canario por cada salida;
    • un e2e que inyecta canarios reales y espera 0 apariciones en todo lo que recibe el receptor OTLP, con un mutante que apaga la redacción y debe salir en rojo.

D8 — Collector y visor local

  • Producción (SigNoz): memory_limiter, resource (deployment.environment), transform/redact y batch. SigNoz deriva el RED de las trazas, así que no hay spanmetrics en producción.
  • Muestreo: por cabeza, parentbased_traceidratio, configurable por entorno (1.0 en el lab). Sin tail sampling por ahora.
  • Enmienda propuesta (2026-10-06) — sondeos periódicos. Un temporizador que sondea a un servicio (hoy el relay y el cierre de sesiones de playback-svc con cmd.media.pollIngestEvents al daemon, una vez por segundo cada uno) abría una traza raíz por iteración: ~136 trazas por minuto, casi todas vacías, que tapaban las trazas de reproducción en el visor. La política:
    • Una iteración sin span activo (la dispara un temporizador, no una petición con traza) y marcada como sondeo no abre el span cliente antes de la llamada: la llamada corre sin span y el sobre no lleva traceparent. Si el resultado trae trabajo (eventos) o la llamada falla, el span cliente se crea a posteriori con su hora de inicio real y queda como raíz; si no, no hay traza. Con un span padre activo no cambia nada.
    • Silencio es: respuesta sin eventos y sin error, o INGEST_UNAVAILABLE (el daemon sin raíz de ingesta, estado estable que el relay ya trata como no-error). Cualquier otro error deja traza.
    • La métrica de duración (styx.daemon.ipc.duration) se graba siempre, y cada iteración suma a styx.playback.ingest.poll.iterations{outcome=empty|events|unavailable|error}: el ritmo del sondeo se ve en métricas, no en trazas.
    • Se descartó un muestreador por nombre de span (daemon.cmd.media.pollIngestEvents a AlwaysOff en la raíz): la decisión se toma al abrir el span, antes de saber si hay eventos, así que perdería justo las trazas con trabajo.
    • Coste asumido: el sondeo con eventos no tiene hijo del daemon, porque el sobre no llevó traceparent. El procesado posterior de cada evento (alta en catalog-svc) sí abre sus propias trazas.
    • Cada nuevo sondeo periódico declara su quietWhen en el sitio de la llamada; no hay lista global de nombres.
  • Visor local: un perfil de compose observability-dev con grafana/otel-lgtm (Tempo, Loki, Prometheus y Grafana) fijado por digest, con su propia configuración de collector que exporta a él en vez de a SigNoz. Grafana se configura con el salto de traza a logs por trace_id.
  • Compose pone OTEL_* y el token en el daemon y en el web.

Consecuencias

  • Se amplía el contrato: PlaybackMetricEvent y traceMode en los contratos de bus. Los dos van contract-first.
  • Cambia la semántica de spire: la respuesta lleva el span del handler y aparece spire.trace en Zig. El formato v2 no se toca. Sale por PR y merge en el repo spire y luego re-pin del zon y de @spire/bus.
  • El mutante ttff-origin del harness pasa a codificar el origen nuevo: el harness espera N segundos antes de pulsar play, y medir desde el montaje del stage debe salir en rojo.
  • Un harness nuevo, trace-proof, demuestra una sola traza a través del navegador, el BFF, playback-svc, catalog-svc y el daemon, con logs de cada uno que llevan el trace_id. Las mutaciones que quitan cada salto deben salir en rojo. Lo verifica alguien distinto del autor (r31).
  • Hay coste: un hilo y una cola en el daemon, una dependencia nueva de OTel en el BFF y spans de arranque por sesión.

Preguntas abiertas

  • P1 — Origen del TTFF con el CTA. ¿El stage arranca la reproducción por su cuenta tras «Reproducir» (t0 = el clic en el CTA) o se queda en pausa (t0 = el play del control)? La propuesta es arrancar por su cuenta y usar el gesto cuando el navegador lo bloquee.
  • P2 — Spans en el navegador. Fase 1: sólo traceparent más las fases del TTFF. Fase 2: el exportador mínimo por el proxy del BFF. ¿Se aprueba la fase 2? Implica CSP connect-src 'self', que ya se cumple, y un endpoint nuevo en el BFF.
  • P3 — Spans del daemon. Se propone exportar spans por OTLP además de los logs correlacionados. Respondida por waxin (2026-10-06): el daemon exporta spans y logs por OTLP. Implementado y probado contra un receptor OTLP real (ver D4 y docs/track/observability/evidence/daemon-otlp/). El resto del ADR sigue PROPOSED.
  • P4 — actorId. ¿Seudónimo HMAC, que permite seguir a un actor sin verlo en claro, o quitarlo?
  • P5 — better-logger 5.x en el web, para el navegador y el SSR, y compartir así @styx/observability en el SSR.
  • P6 — ¿Un gate item OB3 (traza e2e) en track/observability? No se inventa sin el OK de waxin.