Vista generada de dec-0135: Telemetría correlacionada de extremo a extremo: TTFF desde la intención y una traza del clic al daemon
docs/decisions/dec-0135-telemetria-correlacionada-e2e.mdVista generada desde
docs/decisions/dec-0135-telemetria-correlacionada-e2e.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | PROPOSED |
| Fecha | 2026-10-04 |
| Fichero | docs/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-IDque 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)
Leído de docs/decisions/dec-0135-telemetria-correlacionada-e2e.md, el fichero canónico.
track/observability (gate items OB1 y OB2; milestone playback-metrics, ticket
track/observability#02; contrato de redacción M4 de track/observability#01).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.Auditoría de solo lectura sobre fd550eb (2026-10-04). Lo que hay hoy:
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.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.127.0.0.1, sin token, y nadie lo configura en compose.@styx/observability.MeterReader. En los otros cinco servicios, el
histograma HTTP del plugin es un no-op.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.
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.
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.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.web.playback.intent queda implícito, y
sus tiempos llegan como fases del TTFF./_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.@styx/observability.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)).traceparent en Eden.X-Request-ID se borra: el id de correlación es el trace id (DELETE > @deprecated, 0
lectores).daemon.ts crea un span cliente daemon.<contract> y pasa traceparent
al UnixClient.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.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.traceparent en spire sigue dentro de la cabecera firmada; sin tracestate ni baggage en el busRequestKey.of incluye el
traceparent). Una cabecera sin firmar abriría peticiones «iguales» con trazas distintas.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.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.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.daemon.session al cerrarse y un log session.summary.
Nunca etiquetas./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):
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.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.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).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.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.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.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).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.sampled=1; los logs llevan el
trace_id aunque no lo estén.evt que publica el daemon pasan traceparent según el traceMode de su
contrato.sampled. Si es 0, no registra spans, pero los logs
siguen llevando trace_id.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;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.
AsyncLocalStorage (withLogContext({ sessionId }, fn)).@opentelemetry/sdk-logs, en lugar de
OtlpLogTransport) y stdout JSON lines con los mismos campos, para docker logs y
grep por trace id.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.
styx.* donde no, etiquetas cerradas y registradas@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.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).http.server.request.duration;messaging.process.duration y messaging.client.operation.duration, con el estado spire
como etiqueta;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).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.)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}.Authorization,
tokens de invitación o de quick connect, API keys, secretos, emails, ids de actor y paths
absolutos (dec-0117 A8).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).rootId más la relPath oculta (root://<rootId>/[path]). Se oculta, no se hashea: el hash para correlar sigue sin implementar.Bearer, SCT o email) por
[redacted].comptime, y una clave prohibida es @compileError.transform OTTL en el collector con las mismas reglas.check:telemetry-redaction, estático: claves prohibidas en setAttribute, en atributos de
span o log, y headersToSpanAttributes/recordBody en el plugin HTTP;memory_limiter, resource (deployment.environment),
transform/redact y batch. SigNoz deriva el RED de las trazas, así que no hay spanmetrics
en producción.parentbased_traceidratio, configurable por entorno (1.0 en el
lab). Sin tail sampling por ahora.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:
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.INGEST_UNAVAILABLE (el daemon sin raíz
de ingesta, estado estable que el relay ya trata como no-error). Cualquier otro error deja traza.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.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.traceparent. El procesado posterior de cada evento (alta en catalog-svc) sí abre sus propias
trazas.quietWhen en el sitio de la llamada; no hay lista
global de nombres.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.OTEL_* y el token en el daemon y en el web.PlaybackMetricEvent y traceMode en los contratos de bus. Los dos
van contract-first.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.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.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).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.docs/track/observability/evidence/daemon-otlp/). El resto del ADR sigue PROPOSED.actorId. ¿Seudónimo HMAC, que permite seguir a un actor sin verlo en claro, o
quitarlo?@styx/observability en el SSR.OB3 (traza e2e) en track/observability? No se inventa sin el OK de
waxin.