Am argumentat că logurile aplicației n-ar trebui deținute de cloud: aplicația emite, un agent ștampilează cloud-ul pe ele, un backend neutru le stochează. Logurile erau jumătatea ușoară. O linie de log e de sine stătătoare. Un trace nu e: e un arbore de span-uri produse de cinci servicii pe trei clouduri, cusute laolaltă de un ID care trebuie să supraviețuiască fiecărui hop dintre ele, inclusiv unei cozi și unei funcții care nu vorbește HTTP. Dacă ID-ul nu ajunge dincolo, n-ai un trace, ai cinci span-uri fără legătură și aceeași corelare cu ochiul de la 02:40 de care încercam să scăpăm.
E aceeași arhitectură aplicată la tracing, cu părțile care sunt diferite: propagarea, sampling-ul și cum arată query-ul când merge.
Cele trei straturi, din nou
Aplicația folosește SDK-ul OpenTelemetry și nimic specific unui vendor. În Node asta înseamnă @opentelemetry/sdk-node cu auto-instrumentare pentru HTTP, driverul de bază de date, SDK-ul AWS și clientul de coadă. Exportă span-uri prin OTLP către localhost:4317. Habar n-are dacă collector-ul de la celălalt capăt trimite mai departe în Tempo, X-Ray sau într-un fișier.
// tracing.ts, încărcat cu --require înaintea aplicației
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { Resource } from '@opentelemetry/resources';
new NodeSDK({
resource: new Resource({ 'service.name': process.env.OTEL_SERVICE_NAME }),
traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4317' }),
instrumentations: [getNodeAutoInstrumentations()],
}).start();
service.name e singurul atribut pe care îl setează aplicația. Tot ce ține de unde rulează e adăugat mai târziu.
Collector-ul e aceeași configurație de DaemonSet ca pentru loguri, cu un pipeline traces adăugat. Procesorul resourcedetection ștampilează cloud.provider, cloud.region și k8s.cluster.name pe fiecare span, exact cum a făcut pe fiecare linie de log, din același endpoint de metadata, cu zero ramificare între EKS, GKE și AKS.
Backend-ul e Grafana Tempo: OTLP la intrare, TraceQL la ieșire, object storage dedesubt, niciun index de dimensionat. Tempo și Loki stau unul lângă altul în aceeași Grafana, și asta face ca „sari de la linia asta de log la trace-ul ei” să fie un click, nu un copy-paste.
Partea grea: ID-ul trebuie să traverseze golurile
În interiorul unui serviciu, SDK-ul gestionează contextul automat. Între servicii, peste HTTP, header-ul W3C traceparent e injectat de instrumentarea clientului și extras de instrumentarea serverului, și pur și simplu merge. Golurile sunt tot ce nu e un apel HTTP sincron.
Cozile. SQS, Pub/Sub și Service Bus nu poartă header-e HTTP. Producătorul trebuie să pună contextul undeva unde consumatorul îl va găsi, iar consumatorul trebuie să-l scoată și să-și pornească span-ul ca un copil (sau, mai sincer, ca un link, pentru că consumatorul rulează mai târziu și posibil într-un batch). Instrumentarea SDK-ului AWS face asta pentru SQS dacă o folosești, prin atribute de mesaj; pentru alți clienți scrii zece linii:
// producător
const carrier: Record<string, string> = {};
propagation.inject(context.active(), carrier);
await queue.send({ body, attributes: { traceparent: carrier.traceparent } });
// consumator
const parent = propagation.extract(context.active(), { traceparent: msg.attributes.traceparent });
const span = tracer.startSpan('process order', { links: [{ context: trace.getSpanContext(parent)! }] }, parent);
Lambda. Funcția n-are un proces cu viață lungă în care să trăiască SDK-ul, așa că layer-ul OpenTelemetry pentru Lambda împachetează handler-ul, extrage contextul din eveniment (header-e HTTP pentru API Gateway, atribute de mesaj pentru SQS sau un câmp traceparent pe care îl punem noi în payload pentru invocări directe) și golește span-urile înainte ca funcția să înghețe. Golirea e detaliul care contează: fără ea, ultimul span al fiecărei invocări se pierde.
HTTP între clouduri. Nimic special. traceparent e un header ca oricare altul și load balancer-ele de pe toate cele trei clouduri îl lasă să treacă. Singurul lucru de verificat e ca API gateway-ul sau WAF-ul să nu taie header-ele necunoscute; unul de-al nostru a făcut-o, o săptămână, și fiecare trace se termina la margine.
Sampling-ul e locul unde trăiește egress-ul
Trace-urile sunt mai mari decât logurile per request: un singur flux de comandă produce patruzeci de span-uri cu atribute. La scară, să le trimiți pe toate peste granițele cloud-urilor ar costa mai mult decât logurile. Răspunsul e în collector și trebuie să fie tail sampling, nu head sampling: nu poți decide la începutul unui request dacă va fi interesant.
processors:
tail_sampling:
decision_wait: 10s
policies:
- { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
- { name: slow, type: latency, latency: { threshold_ms: 2000 } }
- { name: baseline, type: probabilistic, probabilistic: { sampling_percentage: 10 } }
Păstrezi fiecare trace cu eroare, fiecare trace mai lent de două secunde și unul din zece din rest. Asta ne-a tăiat volumul de trace-uri cu cam 85 % și, în șase luni, n-am vrut niciodată un trace care fusese aruncat: cele interesante sunt, prin construcție, cele păstrate.
Există o capcană specifică multi-cloud-ului. Tail sampling-ul are nevoie ca toate span-urile unui trace să ajungă la aceeași instanță de collector ca să ia o decizie, iar span-urile noastre sunt produse pe trei clustere. Soluția e un collector pe două niveluri: DaemonSet-ul per nod face doar detecția resurselor și batching-ul și trimite mai departe către un deployment mic de collector central (per cloud, sau unul comun) care face tail sampling-ul, cu un load balancer care rutează după trace ID. Încă zece linii de config și o componentă care există doar pentru că trace-ul traversează o graniță.
Cum arată query-ul
Trace-ul pentru incidentul de la 02:40, în TraceQL:
{ trace:id = "4bf92f3577b34da6a3ce929d0e0e4736" }
Fiecare flux de comandă eșuat care a atins clusterul Azure în ultima oră:
{ resource.service.name = "billing" && resource.cloud.provider = "azure" && status = error }
Comenzi mai lente de două secunde unde span-ul lent a fost pe GCP, adică query-ul care găsește „worker-ul e lent, nu API-ul”:
{ resource.service.name = "api" && duration > 2s } >> { resource.cloud.provider = "gcp" && duration > 1500ms }
Și din orice linie de log din Loki cu un trace_id, un click deschide trace-ul în Tempo, pentru că amândouă sunt în aceeași Grafana și amândouă au primit același ID de la același SDK.
Ce rămâne nativ
X-Ray, Cloud Trace și Application Insights sunt produse bune și fiecare e blocat pe un singur cloud. Nu le folosim pentru trace-urile aplicației din același motiv pentru care nu folosim CloudWatch pentru logurile aplicației. Unde își câștigă locul e în interiorul serviciilor managed pe care nu le instrumentăm noi: o execuție AWS Step Functions sau o integrare API Gateway produce segmente X-Ray gratis și sunt utile pentru depanarea acelui serviciu. Nu trebuie să se alăture trace-ului aplicației, iar forțarea lor e mai multă muncă decât merită.
SDK-ul OTel poate propaga formatul de header al X-Ray alături de cel W3C, dacă ai nevoie ca cele două lumi să se întâlnească la o graniță. L-am pornit o dată, pentru un API Gateway care insista, și l-am oprit când gateway-ul s-a mutat.
Versiunea scurtă
Aceeași configurație de collector, încă un pipeline. Aceleași atribute de resurse, încă un backend. Munca nouă e la goluri: inject la producător, extract la consumator, flush înainte ca o funcție să înghețe, sampling la coadă într-un collector care vede tot trace-ul. Odată făcut asta, „de ce e lentă comanda asta” e un query care întoarce răspunsul cu cloud-ul atașat ca label, iar mutarea unui serviciu între clouduri schimbă valoarea label-ului și nimic altceva.
Dacă trace-urile tale se opresc la o coadă sau la o graniță de cloud, am mai cusut golul ăsta.