Universidad Nacional de Luján
Departamento de Ciencias Básicas
Seminario de Integración Profesional 2026 Dr. David Petrocelli
📑 Índice del documento

Trabajo Práctico Nº 2 — Parte 3

Observabilidad — OpenTelemetry: Collector + SDK + multi-backend (vendor-neutral)

Fecha de Entrega: 09/05/2026

🧭 Esto es la Parte 3 de 4 sobre observabilidad.

  • Parte 1 — Loki + Promtail/Alloy + Grafana ✅
  • Parte 2 — EFK = Elasticsearch + Fluent Bit + Kibana ✅
  • Parte 3 (acá) — OpenTelemetry Collector + SDK
  • Parte 4 — Cierre: ADR comparativo de los 3 stacks

En Parte 1 montaron Loki. En Parte 2 montaron EFK. Hoy probablemente la pregunta es: “¿cuál elijo?”. Esta parte responde con una pregunta mejor: “¿por qué tengo que elegir uno?”. OpenTelemetry permite usar los DOS al mismo tiempo, y migrar entre backends sin tocar el código de la app.

🤝 Partes 3 y 4 se entregan el mismo día (09/05). Conviene trabajarlas en paralelo: a medida que avanzan los hits de la Parte 3, vayan tomando las mediciones de la Parte 4 (el top del Pod, las latencias de las queries equivalentes). Cuando llegan al Hit #5 de Parte 3 ya tienen los datos para escribir el ADR magisterial de Parte 4. Un solo push final, dos entregables.

Pre-requisitos: Partes 1 y 2 entregadas. Loki y EFK siguen corriendo (en sus namespaces observability y elastic). En esta parte vamos a desconectar Promtail/Alloy y Fluent Bit, y centralizar todo en OTel Collector que va a hacer fan-out hacia ambos backends.

Pre-requisitos:


Requisitos, consideraciones y formato de entrega

Aplican los mismos requisitos generales del TP 1 y TP 2 · Partes 1 y 2 (repo público, README por hit, video, integración con IA documentada, sin secrets commiteados) más los siguientes específicos de esta Parte 3.

Infra base obligatoria — bloqueante

🚧 Sin esto la entrega no se puede evaluar. La cátedra corre tu helm install + kubectl apply + abre Grafana y Kibana en el browser para verificar que el mismo log entra a los dos backends. Si el fan-out no funciona, se evalúa parcial pero el grueso del TP es justamente eso → el peso del Hit #3 está al 25 % por una razón. No suma puntos en la rúbrica porque es condición necesaria.

Otros requisitos


Contenidos del programa relacionados


Conceptos clave de OpenTelemetry

📚 Esta sección es obligatoria de leer antes de tocar nada. OTel tiene una superficie de API más grande que Loki o EFK — si saltean los conceptos van a copiar YAMLs sin entender qué hacen y cuando algo falle (y va a fallar) no van a saber por dónde empezar. Léanla. Son ~15 min.

Las 3 señales

OTel define 3 tipos de telemetría que cualquier sistema observable produce:

Señal Qué responde Ejemplo en el scraper
Logs “¿Qué pasó en este momento puntual?” INFO Scrape iniciado producto=iphone
Metrics “¿Cuál es el valor agregado de algo en el tiempo?” scraper_pages_total{producto="iphone"} 142
Traces “¿Cómo fluye una operación a través del sistema?” Span “fetch_html” (1.2 s) → “parse_results” (84 ms) → “write_postgres” (12 ms)

🎯 En este TP cubrimos solo logs (obligatorio) + traces (bonus Hit #6). Las métricas las dejamos para otra materia — el scraper es batch, no servidor, y métricas tipo “QPS” no aplican. Si quieren métricas de igual modo, se sumarían como un receiver más en el mismo collector — esa es justamente la elegancia de OTel.

Arquitectura del flujo OTLP

┌─────────────────┐       OTLP/gRPC       ┌──────────────────┐
│  App + OTel SDK │ ────────────────────▶ │  OTel Collector  │
│  (scraper.py)   │       :4317           │  (DaemonSet)     │
└─────────────────┘                       └──────────────────┘
                                                   │
                                          ┌────────┼────────┐
                                          ▼        ▼        ▼
                                       [Loki]  [ElasticSearch]  [Tempo/Jaeger]
                                       (logs)     (logs)         (traces)

3 capas, conceptualmente independientes:

  1. SDK — biblioteca dentro de la app (opentelemetry-sdk para Python). Genera spans, métricas y log records, los serializa a OTLP y los manda al collector. La app no sabe a qué backend van — eso es el punto.
  2. Collector — proceso intermedio. Recibe OTLP (o casi cualquier otro formato — receivers filelog, prometheus, jaeger, zipkin, kafka, etc.), aplica processors (batch, filter, transform, k8sattributes para enriquecer con metadata k8s), y exporta a uno o más backends.
  3. Backends — Loki, Elasticsearch, Prometheus, Tempo, Jaeger, Datadog, New Relic, lo que sea. Los exporters son módulos del collector — agregarle uno nuevo es agregar 5 líneas al YAML.

Por qué OTLP

OTLP (OpenTelemetry Protocol) es gRPC + protobuf (también soporta HTTP/protobuf y HTTP/JSON, pero gRPC es lo recomendado para alto volumen). Su valor no es técnico — los protocolos anteriores también funcionaban — es político: es el primer protocolo de telemetría que los 4 grandes proveedores SaaS aceptaron implementar nativamente.

Protocolo Año origen Vendor-neutral Multi-señal Adopción 2026
Syslog (RFC 5424) 1980s Solo logs Universal pero limitado
Fluentd Forward Protocol 2011 Parcial (Fluent Project) Solo logs Decreciente — Fluent Bit migra a OTLP
InfluxDB Line Protocol 2014 No (InfluxData) Solo metrics Específico de Influx
Loki HTTP push API 2018 No (Grafana Labs) Solo logs Decreciente — Loki ya soporta OTLP nativo desde 2.9
Elasticsearch Bulk API ~2010 No (Elastic) Solo logs Estándar dentro del ecosistema Elastic
OTLP 2019 Sí (CNCF) Logs + Metrics + Traces Estándar de facto desde 2024

El Hit #3 de este TP es la prueba de concepto: el scraper habla un solo protocolo (OTLP), y el collector lo traduce a Loki HTTP push API y a Elasticsearch Bulk API en simultáneo. Si mañana querés sumar Datadog, agregás un exporter más — el scraper no se entera.

Roles del Collector: Agent vs Gateway

Rol Deploy Función Cuándo
Agent DaemonSet (1 pod por nodo) Recolecta telemetría local del nodo (filelog, hostmetrics) y de apps locales Casi siempre — es la base
Gateway Deployment central (replicas: 2-5) Recibe de los agents, aplica processors costosos (sampling, redaction, enriquecimiento global), exporta a backends Producción mediana/grande, cuando los agents son demasiados clientes para el backend o se necesita egress controlado

En este TP usamos solo el rol Agent. Un cluster local con 1 nodo no se beneficia de un gateway. En producción real, lo típico es: agent en cada nodo → gateway central → backends. El TP 2 · Parte 4 lo discute como evolución posible.

Components del Collector

El YAML del OpenTelemetryCollector tiene 4 secciones principales:

Sección Qué hace Ejemplos
receivers Cómo entran los datos otlp (gRPC :4317), filelog (lee archivos), hostmetrics, kafka, prometheus (scraping)
processors Qué se hace antes de exportar batch (agrupa N items o T tiempo), k8sattributes (enriquece con metadata k8s), attributes (rename/insert/delete fields), filter (drop), transform (OTTL — DSL)
exporters A dónde se mandan los datos loki, elasticsearch, otlp (a otro collector), prometheus, jaeger, kafka, debug (stdout)
service.pipelines Cómo se conectan logs: receivers=[filelog,otlp] processors=[batch,k8sattributes] exporters=[loki,elasticsearch]

Hay un quinto tipo, connectors, que son receiver + exporter al mismo tiempo — sirven para hacer pipelines complejos tipo “de spans, generá métricas”. No los usamos en este TP.

CNCF graduated, multi-vendor

OpenTelemetry es CNCF graduated desde noviembre 2023 — el segundo nivel de madurez de la CNCF (junto con Kubernetes, Prometheus, Envoy, etc.), reservado para proyectos que tienen gobernanza estable, múltiples mantenedores corporativos y adopción industrial real. En la práctica esto significa:

OpenTelemetry no es perfecto — honestidad técnica

Antes de venderlo como bala de plata, conviene saber qué duele:

Dicho esto: la alternativa (mantener Promtail + Fluent Bit + algún agente de métricas + algún agente de traces, todos con su propio formato y operación) es peor. OTel gana por consolidación, no por ser perfecto en cada punto.


Práctica

En la Parte 1 y la Parte 2 montaron dos stacks completos de logging centralizado, cada uno con su propio agente de recolección (Promtail / Fluent Bit), su propio backend (Loki / Elasticsearch) y su propia UI (Grafana / Kibana). Ambos funcionan. Y en este momento están duplicando el trabajo: cada nodo del cluster corre dos DaemonSets que leen los mismos archivos /var/log/pods/* y los mandan a dos backends distintos, cada uno con su protocolo, su API y su evolución independiente.

Eso es el problema del 2026 que OTel resuelve. La pregunta no es “¿Loki o Elastic?” — es “¿por qué tengo dos agentes haciendo lo mismo, hablando dos protocolos distintos, y cada uno me ata a un proveedor distinto?”.

La respuesta de la industria desde 2019 — y especialmente desde que CNCF lo graduó en 2023 — es OpenTelemetry: un protocolo común (OTLP), un collector central que sabe hablar con todos los backends, y SDKs que no atan al código de la app a ningún proveedor. El día que mañana decidan cambiar Loki por Datadog, el scraper no cambia una línea.

El Hit clave de esta Parte 3 es el #3: van a configurar el OTel Collector para que el mismo flujo de logs salga simultáneamente a Loki Y a Elasticsearch. Es la prueba de concepto de que la abstracción funciona. A partir de ahí, cambiar de backend deja de ser una migración traumática de meses y pasa a ser editar 5 líneas de YAML.


Hit #1 — Deploy del OpenTelemetry Operator

El OTel Collector se puede deployar de muchas formas (chart Helm directo, manifest a mano, sidecar). En 2026 el camino estándar k8s-native es a través del OpenTelemetry Operator: un operador que define el CRD OpenTelemetryCollector y se encarga de levantar el Deployment/DaemonSet/Sidecar correspondiente cuando vos aplicás un manifest.

Por qué CRD en lugar de Deployment a mano:

1.1 — Namespace y repo Helm

kubectl create namespace otel
kubectl create namespace otel-operator-system

helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm repo update

1.2 — Cert-manager (dependencia del operator)

El OTel Operator usa webhooks admission, y los webhooks requieren TLS. La forma estándar de proveer ese TLS en k8s es con cert-manager, que probablemente ya tengan instalado del TP 2 · Parte 2 (EFK requiere cert-manager para el ECK operator). Si no lo tienen, instalarlo:

helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.16.1 \
  --set installCRDs=true

Verificar que arranque:

kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=120s

1.3 — OpenTelemetry Operator

otel/helm/otel-operator-values.yaml:

manager:
  resources:
    requests: { cpu: 50m, memory: 64Mi }
    limits:   { cpu: 200m, memory: 128Mi }
  # El operator necesita ver pods en todos los namespaces (clusterwide)
  watchNamespace: ""

admissionWebhooks:
  certManager:
    enabled: true   # usa cert-manager para los webhooks (recomendado en prod)

# Instalá los CRDs (OpenTelemetryCollector + Instrumentation)
crds:
  create: true
helm install otel-operator open-telemetry/opentelemetry-operator \
  --version 0.74.0 \
  --namespace otel-operator-system \
  --values otel/helm/otel-operator-values.yaml \
  --wait --timeout 5m

Verificar:

kubectl -n otel-operator-system rollout status deploy/otel-operator-controller-manager --timeout=120s
kubectl get crds | grep opentelemetry
# Esperado:
#   instrumentations.opentelemetry.io
#   opentelemetrycollectors.opentelemetry.io
#   opampbridges.opentelemetry.io

Output esperado del Hit #1

$ kubectl -n otel-operator-system get pods
NAME                                              READY   STATUS    RESTARTS   AGE
otel-operator-controller-manager-xxxxxxxxx-xxxxx  2/2     Running   0          2m

$ kubectl get crds | grep opentelemetry | wc -l
3

Los 2 contenedores en el pod del manager son el manager propiamente dicho y un kube-rbac-proxy que hace authz para el endpoint de métricas — esto es estándar de operadores construidos con kubebuilder.


Hit #2 — OpenTelemetryCollector en modo Agent (DaemonSet) leyendo logs

Ahora desplegamos el Collector como CRD OpenTelemetryCollector en modo DaemonSet. Este es nuestro rol “Agent”: un pod por nodo, leyendo /var/log/pods/ directo del filesystem del host (mismo paradigma que Promtail y Fluent Bit en las Partes 1 y 2 — solo que ahora con OTel).

Pipeline objetivo del Hit #2 (lo que vamos a poner en el CRD):

[receivers]            [processors]                    [exporters]
filelog ────────────▶  batch ────▶ k8sattributes ────▶ debug (stdout solo en este hit)
                       ↓
                   attributes
                   (rename loki/elastic-friendly fields)

En este Hit todavía NO exportamos a Loki/Elastic — usamos solo el exporter debug para confirmar que el pipeline procesa logs correctamente. El fan-out llega en el Hit #3.

2.1 — RBAC: ServiceAccount + ClusterRole

El processor k8sattributes necesita listar pods, namespaces y nodes para enriquecer los logs con metadata k8s. Eso requiere RBAC explícito:

otel/manifests/rbac.yaml:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: otel-collector
  namespace: otel
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: otel-collector
rules:
  - apiGroups: [""]
    resources: ["pods", "namespaces", "nodes"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["replicasets", "daemonsets", "statefulsets", "deployments"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["batch"]
    resources: ["jobs", "cronjobs"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["extensions"]
    resources: ["replicasets"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: otel-collector
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: otel-collector
subjects:
  - kind: ServiceAccount
    name: otel-collector
    namespace: otel
kubectl apply -f otel/manifests/rbac.yaml

2.2 — OpenTelemetryCollector CRD (modo DaemonSet)

otel/manifests/collector-agent.yaml:

apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
  name: agent
  namespace: otel
spec:
  mode: daemonset
  image: otel/opentelemetry-collector-contrib:0.110.0
  serviceAccount: otel-collector
  resources:
    requests: { cpu: 100m, memory: 128Mi }
    limits:   { cpu: 300m, memory: 256Mi }

  # Tolera taints del control plane (k3s single-node)
  tolerations:
    - effect: NoSchedule
      operator: Exists

  # Mountamos /var/log y /var/lib/docker/containers para que filelog pueda leer
  volumeMounts:
    - name: varlog
      mountPath: /var/log
      readOnly: true
    - name: varlibdockercontainers
      mountPath: /var/lib/docker/containers
      readOnly: true
  volumes:
    - name: varlog
      hostPath: { path: /var/log }
    - name: varlibdockercontainers
      hostPath: { path: /var/lib/docker/containers }

  config:
    receivers:
      filelog:
        include:
          - /var/log/pods/ml-scraper_*/*/*.log
        # Empieza al final del archivo en cada boot — evita re-leer histórico
        start_at: end
        include_file_path: true
        include_file_name: false
        operators:
          # Parsea el formato CRI logs (timestamp + stream + flag + log)
          - type: container
            id: container-parser

      # OTLP receiver para el Hit #5 (cuando el scraper exporte directo via SDK)
      otlp:
        protocols:
          grpc: { endpoint: 0.0.0.0:4317 }
          http: { endpoint: 0.0.0.0:4318 }

    processors:
      batch:
        timeout: 10s
        send_batch_size: 1024

      # Enriquece con metadata k8s (namespace, pod, container, labels)
      k8sattributes:
        auth_type: serviceAccount
        passthrough: false
        extract:
          metadata:
            - k8s.namespace.name
            - k8s.pod.name
            - k8s.pod.uid
            - k8s.deployment.name
            - k8s.statefulset.name
            - k8s.daemonset.name
            - k8s.cronjob.name
            - k8s.job.name
            - k8s.node.name
          labels:
            - tag_name: app
              key: app
              from: pod
            - tag_name: component
              key: app.kubernetes.io/component
              from: pod
        pod_association:
          - sources:
              - from: resource_attribute
                name: k8s.pod.uid
          - sources:
              - from: connection

      # Renombrar campos para que sean idiomáticos en cada backend
      attributes:
        actions:
          # Loki prefiere "service" como label primario
          - key: service
            from_attribute: app
            action: insert

    exporters:
      # En este Hit solo debug. Hit #3 agrega loki + elasticsearch.
      debug:
        verbosity: detailed
        sampling_initial: 5
        sampling_thereafter: 200

    service:
      pipelines:
        logs:
          receivers: [filelog, otlp]
          processors: [k8sattributes, attributes, batch]
          exporters: [debug]
      telemetry:
        logs:
          level: info
kubectl apply -f otel/manifests/collector-agent.yaml

Verificar:

kubectl -n otel get otelcol
# NAME    MODE        VERSION   READY   AGE
# agent   daemonset   0.110.0   1/1     2m

kubectl -n otel get ds/agent-collector
# DESIRED   CURRENT   READY   ...   AGE
# 1         1         1                3m

2.3 — Disparar tráfico y validar pipeline

# Disparar un Job manual del scraper (mismo del TP 1 · P2 Hit #7)
kubectl -n ml-scraper create job --from=cronjob/scraper-hourly scraper-otel-test-1
kubectl -n ml-scraper wait --for=condition=complete job/scraper-otel-test-1 --timeout=600s

# Mirar los logs del collector
kubectl -n otel logs ds/agent-collector | grep -A 5 "ResourceLog"

Output esperado del Hit #2

En kubectl logs ds/agent-collector deben ver bloques tipo:

ResourceLog #0
Resource SchemaURL:
Resource attributes:
     -> k8s.namespace.name: Str(ml-scraper)
     -> k8s.pod.name: Str(scraper-otel-test-1-xxxxx)
     -> k8s.cronjob.name: Str(scraper-hourly)
     -> service: Str(scraper)
     -> k8s.node.name: Str(k3d-cluster-server-0)
ScopeLogs #0
LogRecord #0
ObservedTimestamp: 2026-05-22 03:14:22.123 +0000 UTC
Body: Str({"timestamp":"...","level":"INFO","logger":"extractors","message":"Scrape iniciado","producto":"iphone"})

Si ven los bloques con el atributo k8s.namespace.name y compañía, el pipeline filelog → k8sattributes → debug está funcionando. Si ven el Body pero sin los atributos k8s, el processor k8sattributes no está enriqueciendo — revisar el RBAC del 2.1 y los logs del collector buscando errores tipo failed to list pods.

Capturen un screenshot del output del debug exporter y commitéenlo en otel/screenshots/hit2-debug-output.png.


Hit #3 — Fan-out simultáneo a Loki + Elasticsearch (el hit clave)

Este es el hit de la Parte 3. Demostrar que el mismo log entra a los dos backends sin código duplicado, sin agentes duplicados, y cambiar de uno a los dos es agregar líneas a un YAML.

Modifiquen el OpenTelemetryCollector del Hit #2 para:

  1. Sumar dos exporters: loki y elasticsearch.
  2. Listar ambos en el pipeline de logs: exporters: [loki, elasticsearch] (sacar debug o dejarlo, da igual).
  3. Inyectar un campo log_id único por log line para poder verificar visualmente que el mismo evento apareció en los dos backends.

3.1 — Copiar las credenciales de Elastic al namespace otel

Heredado del TP 2 · Parte 2:

kubectl get secret elastic-credentials -n elastic -o yaml \
  | sed 's/namespace: elastic/namespace: otel/' \
  | grep -v 'creationTimestamp\|resourceVersion\|uid\|selfLink' \
  | kubectl apply -f -

Verificar:

kubectl -n otel get secret elastic-credentials -o jsonpath='{.data.password}' | base64 -d
# Tiene que devolver el password de elastic, no estar vacío

3.2 — Modificar el CRD del collector

Agregar a otel/manifests/collector-agent.yaml en la sección exporters (al lado de debug):

    exporters:
      debug:
        verbosity: basic   # bajamos verbosity ahora que el pipeline funciona

      # === Backend #1: Loki ===
      otlphttp/loki:
        endpoint: http://loki.observability.svc.cluster.local:3100/otlp
        # Loki ≥ 3.0 acepta OTLP nativo en el endpoint /otlp
        # (forma "moderna", reemplaza al exporter "loki" legacy)

      # === Backend #2: Elasticsearch ===
      elasticsearch:
        endpoints:
          - https://elasticsearch-master.elastic.svc.cluster.local:9200
        user: elastic
        password: ${env:ELASTIC_PASSWORD}
        tls:
          insecure_skip_verify: true   # cert self-signed del ECK operator
        # Index naming siguiendo la convención del TP 2 · Parte 2
        logs_index: scraper-logs
        # Mappings dinámicos: que el campo "level" sea keyword, no text
        mapping:
          mode: ecs

Y agregar la env var ELASTIC_PASSWORD al spec del collector (al nivel de spec, no dentro de config):

spec:
  mode: daemonset
  image: otel/opentelemetry-collector-contrib:0.110.0
  serviceAccount: otel-collector
  env:
    - name: ELASTIC_PASSWORD
      valueFrom:
        secretKeyRef:
          name: elastic-credentials
          key: password
  # ... resto igual

Y en service.pipelines.logs.exporters reemplazar [debug] por:

    service:
      pipelines:
        logs:
          receivers: [filelog, otlp]
          processors: [k8sattributes, attributes, batch]
          exporters: [otlphttp/loki, elasticsearch]

💡 Por qué otlphttp/loki y no el exporter loki legacy: hasta 2024 OTel tenía un exporter dedicado loki que hablaba el push API HTTP de Loki. Funcionaba pero perdía estructura (Loki guardaba todo como texto plano y los atributos como labels — explosión de cardinality). Desde Loki 3.0 (mid-2024) Loki acepta OTLP nativo en /otlp, preservando la estructura completa de OTel. El exporter loki está deprecado en 0.110+ y se va a eliminar en 0.120+. Usen otlphttp/loki.

3.3 — Inyectar un log_id único para verificar

En el processor attributes, sumar una acción que genere un UUID por log:

      attributes:
        actions:
          - key: service
            from_attribute: app
            action: insert
          - key: log_id
            value: ""
            action: insert
            # Loki/Elastic se completa con un UUID (no se puede en attributes plano,
            # se hace via processor "transform" con OTTL):

Como attributes no genera UUIDs, en lugar de eso usen el processor transform (OTTL — la DSL de transforms del collector):

      transform:
        log_statements:
          - context: log
            statements:
              - 'set(attributes["log_id"], UUID()) where attributes["log_id"] == nil'

Y agreguen transform al pipeline:

        logs:
          receivers: [filelog, otlp]
          processors: [k8sattributes, attributes, transform, batch]
          exporters: [otlphttp/loki, elasticsearch]

Apliquen:

kubectl apply -f otel/manifests/collector-agent.yaml
kubectl -n otel rollout status ds/agent-collector --timeout=120s

3.4 — Disparar tráfico y verificar en los dos backends

kubectl -n ml-scraper create job --from=cronjob/scraper-hourly scraper-fanout-1
kubectl -n ml-scraper wait --for=condition=complete job/scraper-fanout-1 --timeout=600s

Después, en Grafana → Explore → Loki, query:

{service="scraper", k8s_namespace_name="ml-scraper"} | json | line_format "{{.log_id}} {{.message}}"

Copien un log_id cualquiera del output (ejemplo: aa1b2c3d-...) y vayan a Kibana → Discover → index scraper-logs-* → search:

log_id : "aa1b2c3d-..."

Tiene que aparecer el mismo evento en los dos backends. Si solo aparece en uno:

Output esperado del Hit #3

Capturen 2 screenshots side-by-side:

Estos dos screenshots son la entrega del Hit #3. Sin ellos, este hit es 0.


Hit #4 — Reemplazar Promtail/Alloy + Fluent Bit por solo OTel Collector

Si el Hit #3 funcionó, los dos agentes viejos están redundantes — el OTel Collector ya está leyendo los mismos archivos /var/log/pods/* y mandándolos a los dos backends. Es el momento de apagar los agentes viejos y demostrar que el flujo sigue funcionando solo con OTel.

4.1 — Escalar Promtail a 0 (TP 2 · Parte 1)

# Si usaron Promtail (default del TP 2 · P1):
kubectl -n observability scale ds/promtail --replicas=0

# Si optaron por Alloy:
# kubectl -n observability scale ds/alloy --replicas=0

kubectl -n observability get ds
# DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   ...
# 0         0         0       0            0

💡 Por qué scale --replicas=0 y no helm uninstall. Si la cátedra detecta un problema con OTel y necesitan rollback, escalar a 1 vuelve a tener Promtail/Fluent Bit corriendo en 30 segundos. Desinstalar el chart es destructivo y reinstalarlo lleva varios minutos. En producción real, el patrón es siempre el mismo: dejá el sistema viejo “frío” durante el período de bake del nuevo (típico: 1-2 semanas), y solo después desinstalá.

4.2 — Escalar Fluent Bit a 0 (TP 2 · Parte 2)

kubectl -n elastic scale ds/fluent-bit --replicas=0
kubectl -n elastic get ds/fluent-bit
# DESIRED == 0, READY == 0

4.3 — Verificar que el flujo sigue funcionando

kubectl -n ml-scraper create job --from=cronjob/scraper-hourly scraper-otel-only-1
kubectl -n ml-scraper wait --for=condition=complete job/scraper-otel-only-1 --timeout=600s

En Grafana → Explore → Loki:

{service="scraper", k8s_namespace_name="ml-scraper", k8s_job_name="scraper-otel-only-1"}

Tienen que aparecer logs. Y en Kibana, mismo job:

k8s.job.name : "scraper-otel-only-1"

Output esperado del Hit #4

Capturen un screenshot mostrando los 2 DaemonSets viejos en 0 + el Collector OTel activo + el último log en los 2 backends, y commitéenlo en otel/screenshots/hit4-old-agents-down.png.

🎯 Esto es el momento “wow” del TP. Los alumnos típicamente reaccionan acá — porque acaban de eliminar 2 agentes (50 % menos pods, 50 % menos config a mantener) sin perder nada. Y todavía no tocaron código del scraper. Ese es el siguiente hit.


Hit #5 — Instrumentar el scraper con el SDK de OpenTelemetry

Hasta ahora el OTel Collector lee del filesystem (receiver filelog) — exactamente como hacían Promtail y Fluent Bit. Eso funciona pero tiene 2 limitaciones:

  1. Pasa por stdout → CRI → archivo. Cada salto suma latencia y oportunidades de pérdida (rotation, etc.).
  2. No hay correlación con traces. Si querés correlacionar un log con la traza que lo originó (Hit #6 bonus), necesitás que la app emita los dos con un trace_id compartido — eso solo se logra con SDK.

La forma “correcta” en OTel es: la app habla OTLP directo al Collector via gRPC, sin pasar por archivos. Vamos a hacer eso.

5.1 — Instalar las dependencias OTel en el scraper

scraper-instrumentation/requirements-otel.txt:

opentelemetry-api==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-exporter-otlp-proto-grpc==1.30.0

Sumar a requirements.txt del scraper (heredado del TP 1) las 3 líneas anteriores.

5.2 — Reemplazar logging_setup.py

El módulo del TP 2 · Parte 1 emitía JSON a stdout via python-json-logger. Lo reemplazamos por uno que use el LoggingHandler de OTel — emite log records via OTLP/gRPC al collector, sin pasar por archivos.

scraper-instrumentation/otel_setup.py:

"""
Setup de OpenTelemetry SDK: TracerProvider + LoggerProvider con OTLP exporter.
Reemplaza logging_setup.py del TP 2 · Parte 1.
"""

import logging
import os
import socket

from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
    OTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
    OTLPSpanExporter,
)
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor


def setup_otel(service_name: str = "scraper") -> None:
    """
    Configura logging + tracing OTel.

    Lee OTEL_EXPORTER_OTLP_ENDPOINT (default: http://localhost:4317).
    Si la app está en un Pod, este env var se inyecta via ConfigMap apuntando
    al collector DaemonSet en el mismo nodo (ver scraper-otlp-config.yaml).
    """
    endpoint = os.environ.get(
        "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
    )

    # Resource: metadata adjunta a todas las señales
    resource = Resource.create(
        {
            "service.name": service_name,
            "service.version": os.environ.get("APP_VERSION", "dev"),
            "host.name": socket.gethostname(),
            "deployment.environment": os.environ.get("ENV", "tp"),
        }
    )

    # === Tracing ===
    tracer_provider = TracerProvider(resource=resource)
    tracer_provider.add_span_processor(
        BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=True))
    )
    trace.set_tracer_provider(tracer_provider)

    # === Logging ===
    logger_provider = LoggerProvider(resource=resource)
    logger_provider.add_log_record_processor(
        BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint, insecure=True))
    )
    set_logger_provider(logger_provider)

    # Bridge: redirigí el módulo `logging` standard a OTel.
    # Esto significa que todos los `logger.info(...)` del scraper salen via OTLP
    # SIN cambiar ni una línea de los call-sites.
    handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
    logging.basicConfig(level=logging.INFO, handlers=[handler])

5.3 — Diff con el logging_setup.py viejo

Para que vean exactamente qué cambia (y qué NO cambia):

--- logging_setup.py    (TP 2 · P1, JSON a stdout)
+++ otel_setup.py       (TP 2 · P3, OTLP a collector)
@@ -1,18 +1,38 @@
 import logging
-from logging.handlers import RotatingFileHandler
-from pythonjsonlogger.json import JsonFormatter
+import os
+from opentelemetry._logs import set_logger_provider
+from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
+from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
+from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
+from opentelemetry.sdk.resources import Resource

-def setup_logging(log_file="output/scraper.log"):
-    json_formatter = JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")
-    stream_handler = logging.StreamHandler()
-    stream_handler.setFormatter(json_formatter)
+def setup_otel(service_name: str = "scraper"):
+    endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
+    resource = Resource.create({"service.name": service_name})

-    file_handler = RotatingFileHandler(log_file, maxBytes=2_000_000, backupCount=3)
-    logging.basicConfig(level=logging.INFO, handlers=[stream_handler, file_handler])
+    logger_provider = LoggerProvider(resource=resource)
+    logger_provider.add_log_record_processor(
+        BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint, insecure=True))
+    )
+    set_logger_provider(logger_provider)
+
+    handler = LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
+    logging.basicConfig(level=logging.INFO, handlers=[handler])

🎯 Lo que NO cambia: ninguno de los logger.info("Scrape iniciado", extra={"producto": producto, ...}) del Hit #5 del TP 2 · Parte 1. El código de negocio queda idéntico. Eso es el punto de OTel — la instrumentación es transparente al código de la app.

5.4 — Configurar la env var en el Pod

otel/manifests/scraper-otlp-config.yaml:

apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-collector-endpoint
  namespace: ml-scraper
data:
  # Apunta al collector DaemonSet del mismo nodo (status.hostIP del pod)
  # Más resiliente que apuntar al Service: si el collector del nodo se cae,
  # el pod escalado en otro nodo apunta a su collector local.
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://$(NODE_IP):4317"
  OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
  OTEL_SERVICE_NAME: "scraper"

Modificar el CronJob del scraper (TP 1 · P2 Hit #7) para sumar la env NODE_IP desde el fieldRef y montar el ConfigMap como envFrom:

# scraper-cronjob.yaml (extracto del cambio)
spec:
  containers:
    - name: scraper
      image: ghcr.io/<usuario>/scraper:otel-v1
      env:
        - name: NODE_IP
          valueFrom:
            fieldRef:
              fieldPath: status.hostIP
      envFrom:
        - configMapRef:
            name: otel-collector-endpoint

5.5 — Re-publicar imagen y aplicar

# Build + push (usar el Hit #6 del TP 1 · Parte 2 — workflow de CI ya existe)
docker build -t ghcr.io/<usuario>/scraper:otel-v1 .
docker push ghcr.io/<usuario>/scraper:otel-v1

kubectl apply -f otel/manifests/scraper-otlp-config.yaml
kubectl apply -f scraper-cronjob.yaml
kubectl -n ml-scraper create job --from=cronjob/scraper-hourly scraper-sdk-test-1
kubectl -n ml-scraper wait --for=condition=complete job/scraper-sdk-test-1 --timeout=600s

Output esperado del Hit #5

En Loki y en Kibana, los logs del Job scraper-sdk-test-1 deben aparecer con un trace_id y span_id no vacíos en cada log record. Esos campos son lo que distingue un log emitido via SDK de uno parseado del archivo. Query LogQL para verificar:

{service="scraper", k8s_job_name="scraper-sdk-test-1"} | json | trace_id != ""

Si aparecen logs y trace_id está populado, el SDK está exportando correctamente.

⚠️ Pitfall conocido: el BatchLogRecordProcessor agrupa logs antes de exportarlos. Si el scraper termina muy rápido (segundos), pueden perderse los últimos logs que quedaron en el buffer. Llamar siempre logger_provider.shutdown() antes de salir — la doc oficial recomienda registrarlo via atexit.register(...). En el otel_setup.py de arriba lo agregaron como ejercicio.


Hit #6 — Bonus: Traces (opcional, +5 %)

Si el SDK del Hit #5 ya está adentro, sumar traces es trivial: el TracerProvider ya está registrado, solo falta anotar las funciones del scraper que queremos ver como spans, y desplegar un backend de traces.

6.1 — Backend: Jaeger all-in-one

helm repo add jaegertracing https://jaegertracing.github.io/helm-charts
helm install jaeger jaegertracing/jaeger \
  --version 3.4.x \
  --namespace otel \
  --set provisionDataStore.cassandra=false \
  --set provisionDataStore.elasticsearch=false \
  --set storage.type=memory \
  --set agent.enabled=false \
  --set collector.enabled=true \
  --set query.enabled=true \
  --set query.service.type=NodePort \
  --set query.service.nodePort=30002

💡 Storage in-memory: para el TP. En producción se usa Elasticsearch o Cassandra. El TP no requiere persistencia.

6.2 — Sumar exporter otlp/jaeger al collector

En el OpenTelemetryCollector:

    exporters:
      # ... otlphttp/loki, elasticsearch ...
      otlp/jaeger:
        endpoint: jaeger-collector.otel.svc.cluster.local:4317
        tls:
          insecure: true

    service:
      pipelines:
        logs:
          receivers: [filelog, otlp]
          processors: [k8sattributes, attributes, transform, batch]
          exporters: [otlphttp/loki, elasticsearch]
        # === NUEVA pipeline para traces ===
        traces:
          receivers: [otlp]
          processors: [k8sattributes, batch]
          exporters: [otlp/jaeger]

6.3 — Auto-instrumentar requests y urllib3 (las libs de Selenium)

scraper-instrumentation/requirements-otel.txt:

opentelemetry-instrumentation-requests==0.51b0
opentelemetry-instrumentation-urllib3==0.51b0

⚠️ Versionado de instrumentations: las instrumentation libs de Python están en 0.X mientras que la API/SDK están en 1.X. Es a propósito — la spec considera las instrumentations menos estables. La regla: la versión de instrumentation debe matchear la de la API. Para opentelemetry-api==1.30.0 la instrumentation correspondiente es 0.51b0.

En otel_setup.py, sumar al final del setup_otel():

    # Auto-instrumentation de requests y urllib3
    from opentelemetry.instrumentation.requests import RequestsInstrumentor
    from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor

    RequestsInstrumentor().instrument()
    URLLib3Instrumentor().instrument()

Y en los call-sites del scraper, envolver explícitamente los bloques que querés ver como span:

# extractors.py
from opentelemetry import trace

tracer = trace.get_tracer(__name__)

def scrape_producto(producto: str):
    with tracer.start_as_current_span(
        "scrape_producto",
        attributes={"producto": producto},
    ) as span:
        # ... lógica de scraping ...
        span.set_attribute("results.count", len(results))
        return results

6.4 — Verificar en Jaeger UI

Abrir http://<node-ip>:30002 (Jaeger UI) → seleccionar service scraper → Find Traces. Tiene que aparecer una traza tipo:

scrape_producto (1.4 s)
├── HTTP GET https://articulo.mercadolibre.com.ar/... (selenium → requests) (1.1 s)
│   └── HTTP GET https://articulo.mercadolibre.com.ar/... (urllib3) (1.1 s)
└── parse_results (62 ms)

Si la ven, está armado. Capturen screenshot en otel/screenshots/hit6-trace-jaeger.png.

⚠️ Selenium WebDriver no genera spans automáticos — Selenium usa internamente requests/urllib3 cuando habla con el chromedriver via JSON-Wire, pero sus operaciones high-level (driver.get(), find_element) no están auto-instrumentadas. Si querés ver el flow real del scrape como un solo span, tenés que envolverlo manualmente como mostramos arriba.


Cómo entregar

  1. Push final al repo público (mismo repo del TP 1 / TP 2 · P1 y P2, no abrir uno nuevo) antes del 09/05/2026 23:59 ART.
  2. README raíz actualizado con una sección nueva “TP 2 · Parte 3 — OpenTelemetry”:
    • Cómo ejecutar otel/install.sh desde cero (con Loki + EFK ya levantados de las Partes 1 y 2).
    • Variables de entorno requeridas (heredadas de las partes anteriores: GRAFANA_ADMIN_PASSWORD, ELASTIC_PASSWORD).
    • Link al otel/README.md con los detalles.
  3. Carpeta otel/ completa según estructura obligatoria.
  4. docs/adr/0010-instrumentacion-vendor-neutral.md (y opcionalmente 0011-traces-vs-solo-logs.md si hicieron Hit #6).
  5. Carpeta otel/screenshots/ con mínimo:
    • hit2-debug-output.png — output del exporter debug con atributos k8s enriquecidos.
    • hit3-fanout-loki.png — Grafana mostrando un log con su log_id.
    • hit3-fanout-elastic.png — Kibana mostrando el mismo log_id.
    • hit4-old-agents-down.png — Promtail + Fluent Bit en replicas=0, OTel collector activo, último log en los 2 backends.
    • hit5-otlp-trace-id.png — Grafana mostrando logs con trace_id populado (prueba del SDK).
    • (bonus Hit #6) hit6-trace-jaeger.png — traza completa del scraper en Jaeger.
  6. Video corto (3-5 min) mostrando: install.sh corriendo, Promtail/Fluent Bit escalando a 0, un Job nuevo del scraper, mismo log apareciendo en Grafana Y en Kibana en simultáneo (esto es lo que la cátedra mira primero).
  7. Mensaje en el canal Discord de la materia con el link al repo y al video.

📡 Canal Discord (consultas + entregas): https://discord.com/channels/1482135908508500148/1482135909456679139 Antes de pedir ayuda con OTel, revisá la sección Common pitfalls abajo — la mayoría de los problemas son los 5 ahí descritos.


Auto-verificación previa a la entrega

Igual que en TP 2 · Partes 1 y 2: si algo de esta lista falla, no entregues.

1) install.sh corre limpio sobre Loki + EFK ya instalados

# Loki + EFK siguen corriendo del TP 2 · P1 y P2
kubectl get pods -A | grep -E 'loki|elasticsearch|kibana'

# Borrar el namespace otel para arrancar limpio
kubectl delete namespace otel --wait=true || true

# Re-instalar OTel from scratch
cd otel && ./install.sh

# El script DEBE terminar con exit 0 y los ✓ del Hit #1

2) El operator + el collector están Running

kubectl -n otel-operator-system get pods
kubectl -n otel get otelcol,ds,pod
# Esperado:
#  otelcol/agent     READY 1/1
#  ds/agent-collector  DESIRED == READY (1 si k3s single-node)

3) El collector recibe logs del scraper

kubectl -n ml-scraper create job --from=cronjob/scraper-hourly verify-otel
kubectl -n ml-scraper wait --for=condition=complete job/verify-otel --timeout=600s
kubectl -n otel logs ds/agent-collector --since=2m | grep -c k8s.namespace.name
# Esperado: > 0

4) Mismo log_id en Loki Y en Elastic

Disparar un Job nuevo, copiar un log_id aleatorio del output de Loki, buscarlo en Kibana. Tiene que aparecer en los dos.

5) Promtail/Alloy y Fluent Bit están en 0

kubectl -n observability get ds promtail -o jsonpath='{.status.desiredNumberScheduled}'
# Esperado: 0
kubectl -n elastic get ds fluent-bit -o jsonpath='{.status.desiredNumberScheduled}'
# Esperado: 0

6) El scraper exporta OTLP directo (no via filelog)

kubectl -n ml-scraper logs job/verify-otel | head -5
# Tiene que NO haber logs JSON en stdout — el SDK los emite via OTLP, no a stdout
# Si seguís viendo JSON en stdout, el setup_otel() no se está llamando

En Loki: {service="scraper"} | json | trace_id != "" debe devolver records.

7) RBAC del collector OK

kubectl auth can-i list pods --as=system:serviceaccount:otel:otel-collector
# Esperado: yes
kubectl auth can-i list nodes --as=system:serviceaccount:otel:otel-collector
# Esperado: yes

8) gitleaks no detecta secrets

gitleaks detect --no-git --verbose
# Esperado: 0 leaks. Si encuentra el password de Elastic en algún YAML, está mal —
# borralo y movelo a un Secret con secretKeyRef.

Common pitfalls

unknown exporter "loki" o unknown exporter "elasticsearch"

Bajaron otel/opentelemetry-collector (core) en lugar de otel/opentelemetry-collector-contrib. Los exporters de Loki y Elasticsearch están solo en contrib. Cambien la imagen y kubectl apply de nuevo. La diferencia de tamaño no importa en este TP.

k8sattributes no enriquece

Casi siempre es RBAC. El processor consulta el API server para resolver pod → metadata. Si el ServiceAccount no puede listar pods/namespaces, los atributos quedan vacíos pero el collector NO crashea — solo verás el Body del log sin los k8s.* adjuntos. Verificar con:

kubectl auth can-i list pods --as=system:serviceaccount:otel:otel-collector

Si dice no, falta aplicar el rbac.yaml del Hit #2.

Cardinality explosion en Loki via OTel

Cuando OTel exporta a Loki, cada attribute se vuelve un label de Loki por default. Si tu app emite un extra={"trace_id": "...", "request_id": "..."} como antes, esos campos van a explotar la cardinality de Loki igual que en el TP 2 · Parte 1. Solución: en el exporter otlphttp/loki, configurá explícitamente cuáles atributos van como label vs cuáles van en el body. Loki 3.x con OTLP nativo lo maneja mejor que el exporter legacy, pero seguí siendo conservador (≤ 10 labels).

BatchLogRecordProcessor pierde los últimos logs

El batch processor agrupa records por tiempo (default: 5s) o tamaño (default: 512). Si el scraper termina antes del flush, esos logs se pierden. Siempre llamen logger_provider.shutdown() al final, idealmente vía atexit:

import atexit
atexit.register(logger_provider.shutdown)
atexit.register(tracer_provider.shutdown)

El collector arranca pero no exporta (silencioso)

Si en el YAML el nombre de un processor o exporter está mal escrito, el collector arranca pero silenciosamente lo ignora. Verificar siempre los logs del collector buscando WARNINGs:

kubectl -n otel logs ds/agent-collector | grep -i 'warn\|error'

Si ves failed to find exporter o processor not found, ahí está el typo.

otlphttp/loki falla con HTTP 404

Si Loki está pinneado en una versión < 3.0, no tiene el endpoint /otlp. Hay dos caminos:

  1. Upgradear Loki a ≥ 3.0 (chart grafana/loki ≥ 6.x).
  2. Usar el exporter loki legacy en lugar de otlphttp/loki — funciona pero pierde estructura. No recomendado en 2026.

Verificar versión de Loki:

kubectl -n observability exec sts/loki -- /usr/bin/loki -version

Criterios de evaluación

Requisitos bloqueantes (no se acepta la entrega sin estos)

Estos no suman puntos — son condición necesaria para que la entrega sea corregible. Si falta cualquiera, la nota es 0.

Tabla de puntaje (100 %)

Criterio Peso
Hit #1 — OpenTelemetry Operator desplegado + CRDs presentes 10 %
Hit #2OpenTelemetryCollector en modo DaemonSet leyendo logs + k8sattributes enriqueciendo 15 %
Hit #3 — Fan-out simultáneo a Loki + Elasticsearch verificado con log_id matching 25 %
Hit #4 — Promtail + Fluent Bit escalados a 0, flujo sigue funcionando solo con OTel 15 %
Hit #5 — Scraper instrumentado con SDK Python, trace_id populado en log records 20 %
ADR 0010-instrumentacion-vendor-neutral.md justificando OTel vs status quo 15 %
Bonus Hit #6 — Traces visibles en Jaeger + ADR 0011 +5 %

📊 Por qué Hit #3 pesa 25 %: es el corazón del TP. Demuestra la propiedad que justifica OTel en un proyecto real (multi-backend simultáneo). Si el fan-out no funciona, el resto del TP es paja con poca chicha.


Material de apoyo

Diagrama ASCII de los 3 stacks corriendo en paralelo

Estado final esperado tras la Parte 3:

┌─────────────────────────────────────────────────────────────────────────────┐
│                              k3s/k3d cluster                                 │
│                                                                              │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │                       namespace: ml-scraper                           │  │
│  │                                                                       │  │
│  │   ┌───────────────────────────────────────────────────────────┐     │  │
│  │   │        scraper (Pod, CronJob hourly + Job manual)         │     │  │
│  │   │   ┌─────────────────────────────────────────────────┐     │     │  │
│  │   │   │  Python app + OTel SDK 1.30.x                   │     │     │  │
│  │   │   │   - LoggerProvider → BatchLogRecordProcessor    │     │     │  │
│  │   │   │   - TracerProvider → BatchSpanProcessor          │     │     │  │
│  │   │   └────────────┬────────────────────────────────────┘     │     │  │
│  │   └────────────────┼──────────────────────────────────────────┘     │  │
│  └────────────────────┼─────────────────────────────────────────────────┘  │
│                       │ OTLP/gRPC :4317                                     │
│                       ▼                                                     │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │                         namespace: otel                              │  │
│  │                                                                      │  │
│  │   ┌──────────────────────────────────────────────────────┐          │  │
│  │   │  OpenTelemetryCollector (DaemonSet, mode: agent)     │          │  │
│  │   │                                                       │          │  │
│  │   │  receivers:    filelog (/var/log/pods/*) + otlp      │          │  │
│  │   │  processors:   batch + k8sattributes + transform     │          │  │
│  │   │  exporters:    otlphttp/loki + elasticsearch +       │          │  │
│  │   │                otlp/jaeger (bonus)                   │          │  │
│  │   └─────┬──────────────────┬────────────────────┬──────────────┘    │  │
│  └─────────┼──────────────────┼────────────────────┼────────────────────┘  │
│            │                  │                    │                       │
│            ▼                  ▼                    ▼                       │
│  ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐      │
│  │ ns: observability│ │  ns: elastic     │ │  ns: otel (bonus)    │      │
│  │                  │ │                  │ │                      │      │
│  │  Loki 3.x        │ │  Elasticsearch   │ │  Jaeger all-in-one   │      │
│  │  + Grafana 11    │ │  + Kibana        │ │  (in-memory storage) │      │
│  │                  │ │                  │ │                      │      │
│  │  :30000          │ │  :30001          │ │  :30002              │      │
│  └──────────────────┘ └──────────────────┘ └──────────────────────┘      │
│                                                                            │
│  [APAGADOS — escalados a 0]                                                │
│  - DaemonSet promtail (ns: observability)                                  │
│  - DaemonSet fluent-bit (ns: elastic)                                      │
└────────────────────────────────────────────────────────────────────────────┘

Tabla de receivers / exporters útiles

Receivers más usados

Receiver Para qué Notas
otlp OTLP/gRPC + HTTP entrantes El default — apps con SDK lo usan
filelog Leer archivos de log Usado en Hit #2 para /var/log/pods/*
hostmetrics CPU, memory, disk del host Útil para métricas de nodos
kafka Consumir de un topic Para apps que ya emiten a Kafka
prometheus Scrape de endpoints /metrics Reemplaza a Prometheus si querés OTLP-only
k8s_cluster Eventos del API server Para auditoría
k8s_events k8s events (kubectl get events) Útil para detectar OOMKilled, evictions

Exporters más usados

Exporter Backend Notas
otlphttp Cualquier backend OTLP El más universal — reemplazó al loki, prometheus, etc.
otlp Otro collector (gateway pattern) Para HA / fan-in
elasticsearch Elasticsearch Lo usado en Hit #3
prometheus / prometheusremotewrite Prometheus Para integraciones legacy
kafka Kafka topic Para arquitecturas de streaming
debug Stdout del propio collector Indispensable para debuggear — primer exporter que prueban siempre
file Archivo local Útil para offline replay / forensics
awscloudwatchlogs / googlecloud / azuremonitor Cloud nativo Si están en cloud

Esqueleto de install.sh

#!/usr/bin/env bash
set -euo pipefail

NAMESPACE=otel
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo "→ Verificando pre-requisitos: Loki + EFK"
kubectl -n observability get svc loki >/dev/null \
  || { echo "✗ Loki no encontrado en ns observability — TP 2 · P1 no entregado"; exit 1; }
kubectl -n elastic get svc elasticsearch-master >/dev/null \
  || { echo "✗ Elasticsearch no encontrado en ns elastic — TP 2 · P2 no entregado"; exit 1; }

echo "→ Namespace + Helm repo"
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts >/dev/null 2>&1 || true
helm repo update >/dev/null

echo "→ cert-manager (si no está)"
kubectl get ns cert-manager >/dev/null 2>&1 || helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version v1.16.1 --set installCRDs=true --wait --timeout 5m

echo "→ OpenTelemetry Operator"
helm upgrade --install otel-operator open-telemetry/opentelemetry-operator \
  --version 0.74.0 \
  --namespace otel-operator-system --create-namespace \
  --values "$DIR/helm/otel-operator-values.yaml" \
  --wait --timeout 5m

echo "→ RBAC y secrets"
kubectl apply -f "$DIR/manifests/rbac.yaml"
kubectl get secret elastic-credentials -n elastic -o yaml \
  | sed 's/namespace: elastic/namespace: otel/' \
  | grep -v 'creationTimestamp\|resourceVersion\|uid\|selfLink' \
  | kubectl apply -f -

echo "→ OpenTelemetryCollector CRD"
kubectl apply -f "$DIR/manifests/collector-agent.yaml"
kubectl -n "$NAMESPACE" wait --for=condition=ready otelcol/agent --timeout=180s

echo "→ Apagar agentes legacy"
kubectl -n observability scale ds/promtail --replicas=0 || true
kubectl -n elastic scale ds/fluent-bit --replicas=0 || true

NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
echo ""
echo "✓ OpenTelemetry Operator running"
echo "✓ OpenTelemetryCollector CRD aplicado"
echo "✓ Collector DaemonSet running"
echo "✓ Pipeline activo: filelog + otlp → batch → k8sattributes → [loki, elasticsearch]"
echo "✓ Promtail (observability) escalado a 0"
echo "✓ Fluent Bit (elastic) escalado a 0"
echo ""
echo "→ Verificá fan-out:"
echo "    Grafana: http://${NODE_IP}:30000  → Explore → Loki"
echo "    Kibana:  http://${NODE_IP}:30001  → Discover → scraper-logs-*"
echo "    Jaeger:  http://${NODE_IP}:30002  (si hicieron Hit #6)"

chmod +x install.sh antes de commitearlo.

Esqueleto de ADR 0010-instrumentacion-vendor-neutral.md

Mismo formato Michael Nygard. Ejemplo concreto:

# 0010 — Adoptamos OpenTelemetry como capa de instrumentación vendor-neutral

- Date: 2026-05-25
- Status: Accepted
- Deciders: <equipo>

## Contexto

Tras los TP 2 · Partes 1 y 2 tenemos dos stacks completos de logging corriendo en
paralelo: Loki (con Promtail) y Elasticsearch (con Fluent Bit). Ambos funcionan,
pero:
- Estamos corriendo dos DaemonSets que leen los mismos archivos.
- El scraper usa `python-json-logger` que es un detalle de implementación
  específico — si mañana migráramos a Datadog o New Relic, habría que cambiar el
  módulo de logging.
- La decisión "Loki vs Elastic" se siente prematura y arbitraria.

OpenTelemetry es un proyecto CNCF graduated (2023) que define un protocolo
(OTLP) y SDKs vendor-neutral. Los 4 grandes proveedores SaaS (Datadog, New
Relic, Dynatrace, Splunk) y los OSS (Loki, Elasticsearch, Prometheus, Jaeger,
Tempo) soportan OTLP nativo en 2026.

## Decisión

Adoptamos **OpenTelemetry Collector + SDK** como capa de instrumentación
unificada. El collector hace fan-out a Loki Y a Elasticsearch en simultáneo
(prueba de concepto que el modelo funciona). El scraper se re-instrumenta con
el SDK de OTel para Python.

## Consecuencias

- Más fácil: un solo agente por nodo (DaemonSet del collector). Sumar un backend
  nuevo es 5 líneas de YAML. El código del scraper no cambia (call-sites
  idénticos al TP 2 · P1).
- Más difícil: una capa más de YAML para mantener (CRDs, config del collector).
  El SDK de Python es menos maduro que Java/Go — esperar bugs ocasionales.
  Aprender OTLP, processors, OTTL.
- Sacrificio: más latencia de export (batch processor) vs hablar HTTP/JSON
  directo a Loki — pero medible solo en sub-ms, irrelevante en un scraper.
- Riesgo: el SDK de Python aún tiene la API de logs marcada "stable since 2024"
  pero todavía con cambios menores entre minor versions. Pinear `==1.30.x`.

## Referencias

- OTel CNCF graduation: https://www.cncf.io/announcements/2023/11/06/cloud-native-computing-foundation-announces-opentelemetry-graduation/
- OTLP spec: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/otlp.md
- Comparativa: TP 2 · Parte 4 (ADR comparativo final)

Referencias y Bibliografía

Solo lo directamente vinculado a lo que se les pide en este TP. Los libros generales de Kubernetes / observabilidad viven en TP 0 / TP 2 · P1 y no se repiten.

Hit #1 — OpenTelemetry Operator

Hit #2 — Collector como CRD + receivers/processors

Hit #3 — Fan-out Loki + Elasticsearch

Hit #5 — Python SDK + LoggingHandler

Hit #6 — Tracing (bonus)

OpenTelemetry — fundamentos

Honestidad técnica — limitaciones de OTel