11 min čteníJohnny UnarJohnny Unar

Observabilita na edge je hlavně problém telemetry

Next.js 15 edge functions a služby v Go 1.22 potřebují jinou observabilitu než dlouho běžící servery. Měřte cold starty, sbírejte sampled flamegraphy a u trace dělejte agresivní tail sampling.

nejdřív se rozbije viditelnost

Edge a serverless často schytají vinu za problémy, které samy nevytvořily. Skoky v latenci, náhodné timeouty, drift p95, který se objeví jen v jednom regionu, cache missy, které zmizí dřív, než je někdo stihne zreprodukovat, to všechno se hodí do škatulky „riziko architektury“. Reálný problém přitom bývá jinde: většina týmů nasadí tyhle runtime s observabilitou navrženou pro Kubernetes pod, který žije tři dny.

Tenhle model se rozpadne rychle. Route handler v Next.js 15 běžící v edge runtime má krátký život, omezené API, divné hranice exekuce a mnohem menší prostor pro invazivní profiling, na který byli lidi zvyklí z warm Node procesu. Go 1.22 backend za ním má opačný profil: stabilní lifetime procesu, lepší profiling hooky, jednodušší propagaci contextu a dost CPU času na to, aby vůbec vyemitoval použitelnou telemetry. Když obě vrstvy instrumentujete stejně, dostanete to horší z obou světů: nafouklý účet, chybějící trace a dashboard plný průměrů, které zdvořile schovají průšvihy.

Pattern, který se nám ve steezr osvědčil nejvíc, napříč zákaznickými portály, AI document pipelines i pár interními systémy s edge auth a regionálním routováním, je docela jednoduchý. Měřte cold starty explicitně, profily sbírejte přes agresivní sampling místo permanentního profilingu všude a u distribuovaných trace dělejte tail sampling podle latence, status code a důležitosti route, místo abyste všechno head-samplovali na 10 % a doufali, že zajímavé selhání zrovna prošlo.

Je taky potřeba přijmout, že observabilita na edge je z definice ztrátová. To je v pohodě. Dobré systémy umí fungovat s užitečnou ztrátou. Špatné systémy předstírají, že si můžou nechat každou event navždy, a pak přijde první faktura a finance observabilitu seškrtají. Cíl je držet signály, které rychle odpoví na produkční otázky, ne archivovat každý span, který vygeneruje bot mlátící do preview deploymentu.

traceujte cold starty napřímo

Cold starty si zaslouží telemetry první kategorie, protože kontaminují všechno kolem sebe. Když vaše edge function stráví 180 ms bootem, vinu schytá downstream fetch do Go API, synthetic checky ukazují na špatnou službu a incident review se změní v soutěž o nejlepší odhad.

V Next.js 15 to instrumentujte v instrumentation.ts a v route nebo middleware entrypointu, kde execution opravdu začíná. Klíč je process-local marker, který existuje jen při první invokaci daného isolate, a pak span attribute, podle kterého se dá později filtrovat. Edge runtime vám ne vždy dá bohaté process primitivy, ale globalThis většinou stačí.

ts
1// instrumentation.ts
2import { trace, context, SpanStatusCode } from '@opentelemetry/api'
3
4const tracer = trace.getTracer('next-edge')
5
6if (!(globalThis as any).__boot_ts) {
7 ;(globalThis as any).__boot_ts = Date.now()
8 ;(globalThis as any).__cold = true
9}
10
11export async function register() {}
12
13export async function markInvocation<T>(name: string, fn: () => Promise<T>) {
14 return tracer.startActiveSpan(name, async span => {
15 const cold = !!(globalThis as any).__cold
16 const bootTs = (globalThis as any).__boot_ts as number
17 span.setAttribute('runtime.name', 'nextjs-edge')
18 span.setAttribute('deployment.environment', process.env.NODE_ENV || 'unknown')
19 span.setAttribute('serverless.cold_start', cold)
20 if (cold) {
21 span.setAttribute('serverless.init_duration_ms', Date.now() - bootTs)
22 ;(globalThis as any).__cold = false
23 }
24 try {
25 return await fn()
26 } catch (err: any) {
27 span.recordException(err)
28 span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message })
29 throw err
30 } finally {
31 span.end()
32 }
33 })
34}

Pak uvnitř route handleru:

ts
1import { markInvocation } from '@/instrumentation'
2
3export const runtime = 'edge'
4
5export async function GET(req: Request) {
6 return markInvocation('edge.products.GET', async () => {
7 const t0 = performance.now()
8 const res = await fetch(`${process.env.API_BASE_URL}/products`, {
9 headers: { 'x-request-id': crypto.randomUUID() }
10 })
11 const body = await res.text()
12 const duration = performance.now() - t0
13 return new Response(body, {
14 status: res.status,
15 headers: {
16 'content-type': res.headers.get('content-type') || 'application/json',
17 'x-upstream-duration-ms': duration.toFixed(1)
18 }
19 })
20 })
21}

Attribute serverless.cold_start=true je důležitější, než si lidi myslí. Dejte ji na root span, rozbijte podle ní latency grafy a půlka záhad zmizí. Na Go straně otiskněte do stejné trace stáří containeru nebo uptime procesu, ať odlišíte edge cold start od churnu v backendu. Bez toho budete týdny nadávat na Postgres, i když se ve skutečnosti jen bootoval čerstvý isolate ve Frankfurtu.

tail sampling, nebo se v tom utopíte

Head sampling je v pohodě u low-volume systémů a průměrný všude jinde. Kritické edge cesty potřebují tail sampling, protože trace je zajímavá až ve chvíli, kdy ji vidíte celou: status code, retrye, celkové trvání, cache miss, jestli uživatel skončil na checkoutu, nebo na nějaké neškodné marketingové route.

OpenTelemetry Collector dává dost kontroly na to, aby byl užitečný, aniž by se z toho stal science project. Pipeline držte nudnou. Přijímejte OTLP přes gRPC a HTTP, batchujte, trochu obohaťte attributes a pak dělejte tail sampling podle explicitních pravidel. Tohle je config collectoru, který jsme používali jako výchozí bod:

yaml
1receivers:
2 otlp:
3 protocols:
4 grpc:
5 endpoint: 0.0.0.0:4317
6 http:
7 endpoint: 0.0.0.0:4318
8
9processors:
10 memory_limiter:
11 check_interval: 1s
12 limit_mib: 512
13 spike_limit_mib: 128
14
15 batch:
16 send_batch_size: 2048
17 timeout: 2s
18
19 attributes/edge:
20 actions:
21 - key: service.namespace
22 value: edge-platform
23 action: upsert
24
25 tail_sampling:
26 decision_wait: 8s
27 num_traces: 100000
28 expected_new_traces_per_sec: 2500
29 policies:
30 - name: errors
31 type: status_code
32 status_code:
33 status_codes: [ERROR]
34 - name: slow-traces
35 type: latency
36 latency:
37 threshold_ms: 750
38 - name: cold-starts
39 type: string_attribute
40 string_attribute:
41 key: serverless.cold_start
42 values: ["true"]
43 - name: checkout-routes
44 type: string_attribute
45 string_attribute:
46 key: http.route
47 values: ["/api/checkout", "/api/login"]
48 - name: probabilistic-baseline
49 type: probabilistic
50 probabilistic:
51 sampling_percentage: 2
52
53exporters:
54 otlp/lightstep:
55 endpoint: ingest.lightstep.com:443
56 headers:
57 lightstep-access-token: ${LIGHTSTEP_ACCESS_TOKEN}
58 debug:
59 verbosity: basic
60
61service:
62 pipelines:
63 traces:
64 receivers: [otlp]
65 processors: [memory_limiter, attributes/edge, tail_sampling, batch]
66 exporters: [otlp/lightstep, debug]

Pár praktických poznámek. decision_wait: 8s je dost dlouhé pro většinu request chainů, které skáčou z edge do API, do Redis a do Postgres, a zároveň dost krátké na to, aby collector nežral paměť donekonečna. Pravidlo cold-starts zachrání trace, které byste při head samplingu skoro jistě neviděli. Základních 2 % drží dost normálního provozu pro srovnání. U low-traffic služeb to zvedněte, u hlučných anonymních route to stáhněte.

Jestli na frontendu a edge vrstvě používáte Sentry pro tracing, držte client-side head sampling nízko a per-route. Něco jako tohle funguje, aniž byste pálili peníze:

ts
1Sentry.init({
2 dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
3 tracesSampler: samplingContext => {
4 const name = samplingContext.name || ''
5 const attrs = samplingContext.attributes || {}
6 if (attrs['serverless.cold_start'] === true) return 1.0
7 if (name.includes('/api/checkout')) return 0.5
8 if (name.includes('/api/login')) return 0.3
9 return 0.02
10 },
11 profilesSampleRate: 0.0
12})

Ne, 100 % u cold startů není drahé, pokud jsou cold starty vzácné. Pokud vzácné nejsou, máte problém v deploymentu a traffic shapingu, a trace vám to pomůžou dokázat.

profily chtějí disciplínu

Flamegraphy jsou pořád nejrychlejší způsob, jak zabít špatné domněnky, hlavně u Go služeb, kde všichni přísahají, že bottleneck je síťová latence, a pprof mezitím ukáže 37 % CPU v JSON encodingu nebo regex schovaný ve validaci requestu. Go vrstvu profilujte vždycky. Edge vrstvu profilujte opatrně, a jen pokud to vaše platforma umí bez hacků.

Go 1.22 vám dá základ skoro zadarmo. Vystrčte net/http/pprof na interní port, zamkněte ho, během incidentů stahujte krátké CPU profily a kde to dává smysl, zapněte continuous profiling, Pyroscope, Parca, Grafana Cloud Profiles, vyberte si. Overhead sample-based profilingu bývá v pohodě, když collection intervaly držíte při zemi.

go
1import (
2 _ "net/http/pprof"
3 "net/http"
4 "log"
5)
6
7func startDebugServer() {
8 go func() {
9 mux := http.NewServeMux()
10 mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
11 w.WriteHeader(http.StatusOK)
12 _, _ = w.Write([]byte("ok"))
13 })
14 mux.Handle("/debug/pprof/", http.DefaultServeMux)
15 if err := http.ListenAndServe("127.0.0.1:6060", mux); err != nil {
16 log.Printf("pprof server failed: %v", err)
17 }
18 }()
19}

Pak si z podu nebo VM stáhněte 30sekundový profil:

bash
1go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/profile?seconds=30

Když profil ukáže dominanci runtime.mallocgc a encoding/json, nepřidávejte další collectory a další dashboardy. Opravte allocation churn nebo přepněte hot pathy na jsoniter či předpočítané response buffery tam, kde to má reálný dopad. Když je obří wait time v database/sql, flamegraph to za vás nevyřeší, ale aspoň uřízne špatný flamewar ve Slacku.

Profiling v Next.js edge je horší, protože jste uvnitř hostovaného isolate s omezenou introspekcí. Built-in observabilita od Vercelu se zlepšila, ale pro analýzu CPU na úrovni kódu se většinou víc dozvíte, když stejnou logiku reprodukujete v Node runtime nebo drahou práci přesunete do Go, kde existuje pprof a nelže. Není to argument o čistotě architektury. Je to argument o nástrojích. Rozpočet na observabilitu utrácejte tam, kde je instrumentation surface skutečná.

Čemu bych se vyhnul, je always-on profiling s vysokou frekvencí napříč každou backend službou. Tým tu myšlenku miluje zhruba dva týdny, než vystřelí retention náklady a nikdo nedokáže vysvětlit, proč potřebují profily pro cron worker, který běží šestkrát denně.

propagujte context čistě

Distribuované tracingy umírají ve chvíli, kdy se propagace contextu začne flákat. Edge systémy se v tomhle rozbijí rychle, protože request může projít browser kódem, middleware, route handlery, fetch hranicemi, API gatewayí a pak ještě Go službou, která si pouští gorutiny pro paralelní lookupy. Jeden chybějící hop v headeru a z trace je dekorativní konfety.

Držte se W3C Trace Contextu, tedy traceparent a tracestate, pokud nemáte opravdu dobrý důvod dělat něco jiného. V Next.js 15 edge handlerech tyhle headery přeposílejte explicitně při každém interním fetchi. Nespoléhejte na to, že to platforma dělá konzistentně za vás.

ts
1const traceparent = req.headers.get('traceparent')
2const tracestate = req.headers.get('tracestate')
3
4const upstream = await fetch(`${process.env.API_BASE_URL}/v1/session`, {
5 headers: {
6 'traceparent': traceparent || '',
7 'tracestate': tracestate || '',
8 'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID()
9 }
10})

V Go použijte OTel HTTP middleware a přestaňte si ručně bastlit napůl rozbité span setupy, pokud vás teda baví debugovat chybějící parent ID ve dvě ráno.

go
1import (
2 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
3 "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
4 "go.opentelemetry.io/otel/sdk/resource"
5 sdktrace "go.opentelemetry.io/otel/sdk/trace"
6)
7
8handler := otelhttp.NewHandler(apiMux, "go-api")
9
10srv := &http.Server{
11 Addr: ":8080",
12 Handler: handler,
13}

Pak přidejte attributes, které při triage opravdu pomůžou: region, deployment ID, cache hit, tenant ID, pokud to dovolí váš privacy model, edge colo, bucket upstream timeoutu. Vanity attributes, na které se nikdo nikdy neptá, klidně vynechte. Pořád vídám spany nacpané padesáti tagy jen proto, že si někdo přečetl blog od vendora a nadchl se. Jen to nafukuje storage a zpomaluje dotazy.

Nejlepší trace attribute je ta, podle které budete během incidentu filtrovat. U edge systémů je to typicky serverless.cold_start, cloud.region, http.route, http.status_code, cache.hit a deploy marker typu service.version nebo Git SHA. Všechno ostatní si musí obhájit existenci.

retence bez výčitek

Rozpočet na observabilitu potřebuje reálná čísla. Jinak tým buď sbírá málo a letí naslepo, nebo si nechá všechno 30 dní a pak se tváří překvapeně, že telemetry je najednou jedna z pěti největších infra položek.

Rozumný start pro produkt střední velikosti, řekněme 50 milionů requestů měsíčně, Next.js edge frontend, pár Go API, PostgreSQL a Redis, může vypadat takhle. Metrics držte 30 dní jen u high-cardinality service dashboardů, a to ještě jen pokud to backend zvládá, jinak po 7 dnech downsamplujte. Trace držte v plné věrnosti u nasamplovaného důležitého provozu 7 dní, pak už jen index-only nebo agregované pohledy do 30 dní. Profily držte 3 až 7 dní, pokud zrovna aktivně neřešíte výkon. Logy kratší, než by si všichni přáli, typicky 7 dní pro aplikační logy, delší jen pro audit eventy a security-relevant streamy.

Hrubý budget pro trace. Počítejme, že tail sampling zachová 100 % chyb, 100 % cold startů, 100 % checkout a auth route a 2 % zbytku. Hodně týmů skončí po složení všech pravidel někde mezi 3 až 6 % efektivní retence. Když má uložená trace v průměru 12 KB po kompresi a měsíčně jich zachováte 2,5 milionu, jste zhruba na 30 GB čistého trace payloadu ještě před indexačním overheadem. Účet nedělají byty na drátu, ale indexace a query engine. Plánujte podle ceny za indexované spany u vendora, ne jen podle storage na disku.

U Sentry držte sampling transakcí agresivní na hot business cestách a skoro nulový jinde. U Lightstepu nebo jiného OTLP backendu stlačte retenci drahých raw trace dolů a pokud to vendor umí, exportujte z collectoru dlouhověké RED metrics přes span-to-metrics. Pokud self-hostujete, stacky postavené na ClickHouse umí být rychle nákladově efektivní, ale bez debat potřebují někoho v týmu, kdo rozumí merge chování, TTL a tomu, proč vám naivní schema zničí query performance.

Jedno praktické pravidlo, kterému věřím víc než jakékoli pricing kalkulačce: když se dva týdny nikdo nepodíval na nějakou třídu telemetry, zkraťte retenci nebo snižte sample rate. Data v observabilitě si na sebe musí vydělat.

Právě tady je vidět seniorní úsudek. Kritické auth failure si zaslouží štědrou retenci. Hlučný veřejný search endpoint, do kterého střílí crawlery, ne. Peníze utrácejte za trace, které vysvětlí bolest uživatelů a riziko pro revenue, a zbytek si nechte jen tak dlouho, aby šly poznat trendy.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Next.js 15 edge functions a služby v Go 1.22 potřebují jinou observabilitu než dlouho běžící servery. Měřte cold starty, sbírejte sampled flamegraphy a u trace dělejte agresivní tail sampling.