Observabilidade em Zig: Logs Estruturados e Métricas Prometheus
Serviços em produção precisam de observabilidade: a capacidade de responder rapidamente “o que está acontecendo agora?” e “por que isso falhou?”. As três pernas clássicas são logs, métricas e traces. Zig não traz um framework de observabilidade pronto na biblioteca padrão, mas a combinação de std.log, um writer JSON simples e um endpoint Prometheus entrega cobertura sólida para a maioria dos serviços.
Este guia mostra como implementar, do zero, logging estruturado JSON e métricas no formato Prometheus exposition, prontos para Grafana, Loki, Datadog, CloudWatch ou qualquer stack moderna.
Os Três Pilares da Observabilidade
| Pilar | Responde a | Formato típico |
|---|---|---|
| Logs | O que aconteceu, em detalhe | Linhas de texto ou JSON |
| Métricas | Como está o sistema, agregado | Séries temporais (counter, gauge, histogram) |
| Traces | Por que uma requisição foi lenta | Spans encadeados por trace_id |
Para começar de forma pragmática, foque em logs estruturados e métricas Prometheus — os dois retornam valor imediato e cobrem 90% das necessidades de monitoramento.
Logging Estruturado em JSON
Logs em texto livre são difíceis de filtrar em escala. JSON estruturado, em contraste, é parseado sem ambiguidade e permite consultas como level=error AND route=/checkout em Loki, Elasticsearch ou Datadog.
A std.log do Zig escreve para stderr com níveis (Debug, Info, Warn, Error) e suporta escopos nomeados via std.log.scoped. Para produzir JSON estruturado, vamos construir um helper simples:
const std = @import("std");
pub const LogLevel = enum {
debug, info, warn, err,
pub fn toString(self: LogLevel) []const u8 {
return switch (self) {
.debug => "debug",
.info => "info",
.warn => "warn",
.err => "error",
};
}
};
pub const Logger = struct {
out: std.fs.File.Writer,
level: LogLevel,
pub fn log(self: *Logger, level: LogLevel, comptime fmt: []const u8, args: anytype, trace_id: []const u8) void {
if (@intFromEnum(level) < @intFromEnum(self.level)) return;
const msg = std.fmt.allocPrint(std.heap.page_allocator, fmt, args) catch return;
defer std.heap.page_allocator.free(msg);
const ts = std.time.timestamp();
self.out.print(
\\{{"ts":{d},"level":"{s}","trace_id":"{s}","msg":"{s}"}}
++ "\n",
.{ ts, level.toString(), trace_id, msg },
) catch return;
}
};
Uso típico em um handler HTTP:
var logger = Logger{
.out = std.io.getStdErr().writer(),
.level = .info,
};
logger.log(.info, "request method={s} path={s} status={d} duration_ms={d}", .{ method, path, status, duration }, trace_id);
Saída:
{"ts":1718956800,"level":"info","trace_id":"a3f2","msg":"request method=GET path=/checkout status=200 duration_ms=12"}
Para escapar aspas dentro de strings reais, troque o msg por um objeto JSON montado com std.json.stringify, que cuida de escaping corretamente para valores dinâmicos.
Boas práticas para logs em produção
- Sempre inclua
trace_id: gerado no início de cada request e propagado para downstream. - Logue em stderr: deixa stdout livre para output de comando e facilita coleta por Docker/journald.
- Use níveis com disciplina:
infopara eventos de negócio,warnpara degradações recuperáveis,errorpara falhas que precisam de ação. - Evite dados sensíveis: nunca logue tokens, senhas, PII em texto puro.
- Log estruturado desde o início: migrar de texto livre para JSON depois é caro.
Métricas no Formato Prometheus
Prometheus coleta métricas fazendo scrape periódico de um endpoint HTTP /metrics. Esse endpoint retorna texto simples no formato exposition, que qualquer linguagem consegue gerar. Não é necessário SDK proprietário.
Exemplo de saída válida para /metrics:
# HELP http_requests_total Total de requisições HTTP por rota.
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/home",status="200"} 4523
http_requests_total{method="POST",route="/checkout",status="500"} 7
# HELP http_request_duration_seconds Latência das requisições.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{route="/home",le="0.005"} 4300
http_request_duration_seconds_bucket{route="/home",le="0.01"} 4450
http_request_duration_seconds_bucket{route="/home",le="0.05"} 4510
http_request_duration_seconds_bucket{route="/home",le="0.1"} 4520
http_request_duration_seconds_bucket{route="/home",le="+Inf"} 4523
http_request_duration_seconds_sum 12.34
http_request_duration_seconds_count 4523
# HELP go_goroutines Conexões ativas.
# TYPE process_open_connections gauge
process_open_connections 42
Implementando um Counter thread-safe
const std = @import("std");
pub const Counter = struct {
value: std.atomic.Value(u64) = .{ .raw = 0 },
help: []const u8,
name: []const u8,
pub fn init(name: []const u8, help: []const u8) Counter {
return .{ .name = name, .help = help };
}
pub fn inc(self: *Counter) void {
_ = self.value.fetchAdd(1, .monotonic);
}
pub fn add(self: *Counter, n: u64) void {
_ = self.value.fetchAdd(n, .monotonic);
}
pub fn write(self: *Counter, writer: anytype) !void {
try writer.print("# HELP {s} {s}\n", .{ self.name, self.help });
try writer.print("# TYPE {s} counter\n", .{ self.name });
try writer.print("{s} {d}\n", .{ self.name, self.value.load(.monotonic) });
}
};
Gauge para valores que sobem e descem
pub const Gauge = struct {
value: std.atomic.Value(u64) = .{ .raw = 0 },
name: []const u8,
help: []const u8,
pub fn set(self: *Gauge, v: u64) void {
self.value.store(v, .monotonic);
}
pub fn write(self: *Gauge, writer: anytype) !void {
try writer.print("# HELP {s} {s}\n", .{ self.name, self.help });
try writer.print("# TYPE {s} gauge\n", .{ self.name });
try writer.print("{s} {d}\n", .{ self.name, self.value.load(.monotonic) });
}
};
Expondo o endpoint /metrics
Agregue todos os counters/gauges em um struct global e sirva via HTTP. Exemplo conceitual:
var requests_total = Counter.init("http_requests_total", "Total de requisições HTTP.");
var open_connections = Gauge.init("process_open_connections", "Conexões abertas.");
fn metricsHandler(writer: anytype) !void {
try requests_total.write(writer);
try open_connections.write(writer);
}
Aponte o Prometheus para http://seu-servico:8080/metrics com scrape_interval: 15s. Em segundos, os dashboards no Grafana começam a aparecer.
Histograma de Latência
Para responder “qual o p99 da latência do checkout?”, você precisa de um histograma. A ideia é dividir observações em buckets e expor contagem por bucket + soma + total. O Prometheus computa p50, p90, p99 via histogram_quantile().
pub const Histogram = struct {
buckets: []const f64, // limites, ex: {0.005, 0.01, 0.05, 0.1, 0.5, 1, +Inf}
counts: []std.atomic.Value(u64),
sum: std.atomic.Value(u64) = .{ .raw = 0 }, // soma em microssegundos p/ inteiro
total: std.atomic.Value(u64) = .{ .raw = 0 },
name: []const u8,
help: []const u8,
pub fn observe(self: *Histogram, value_seconds: f64) void {
const us: u64 = @intFromFloat(value_seconds * 1_000_000);
_ = self.sum.fetchAdd(us, .monotonic);
_ = self.total.fetchAdd(1, .monotonic);
for (self.buckets, 0..) |bound, i| {
if (value_seconds <= bound) {
_ = self.counts[i].fetchAdd(1, .monotonic);
}
}
}
};
No Grafana, a query típica para p99 é:
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))
Composição: Logs + Métricas + Trace ID
O padrão produtivo em serviços Zig reais:
- Middleware HTTP gera
trace_id(UUID ou timestamp+random) no início de cada request. - Counter
http_requests_totalé incrementado a cada resposta, com labels derouteestatus. - Histograma
http_request_duration_secondsobserva a duração de cada request. - Logger estruturado emite uma linha JSON por request com
trace_id,method,path,status,duration_ms. - Gauge
process_open_connectionsreflete conexões ativas.
Com isso, quando um alerta no Grafana dispara, você segue o trace_id do pico de latência até o log correspondente, e do log para o span (se tracing estiver habilitado).
OpenTelemetry em Zig
Ainda não há SDK oficial OTel estável para Zig em 2026. As opções práticas:
- Métricas via Prometheus: cobre a maior parte das necessidades.
- Traces via OTLP HTTP manual: gere spans em JSON OTLP e poste para o collector.
- Bindings C: linkar a biblioteca C do OTel SDK via
@cImportpara traces completos. - W3C Trace Context: propague o header
traceparentmanualmente entre serviços.
Para a maioria dos serviços pequenos e médios, Prometheus + Loki (para logs JSON) já entrega observabilidade robusta sem precisar de OTel completo.
Erros Comuns a Evitar
- Métricas de alta cardinalidade: nunca use
user_idouemailcomo label — explode a memória do Prometheus. - Logs verbosos em hot path: logar dentro de loops apertados degrada performance.
- Esquecer
# TYPE: sem isso, o Prometheus pode inferir o tipo errado e quebrar queries. - Buckets mal calibrados: buckets default não servem para todo serviço; ajuste ao seu SLO.
- stderr não coletado: configure Docker/journald/k8s para capturar stderr do processo Zig.
Conclusão
Observabilidade em Zig é direto de implementar porque os formatos (JSON para logs, texto simples para Prometheus) são agnósticos de linguagem. Com um logger JSON, alguns wrappers atômicos sobre std.atomic.Value e um endpoint /metrics, você entrega dashboards e alertas profissionais sem depender de SDKs pesados. Comece por logs estruturados com trace_id e um counter de requests; adicione histograma de latência e gauges de recursos conforme o serviço amadurece.
Para aprofundar a stack de produção, leia também nosso guia de Servidor HTTP em Zig para Produção, o tutorial de Zig com Docker (para empacotar o serviço com healthcheck) e o artigo sobre Cron Jobs em Zig para Produção, que mostra como orquestrar tarefas recorrentes observáveis.