Jornada para contribuir com o OTel Collector, parte 2

Na parte 1 eu listei o que precisava aprender. Antes de estudar qualquer coisa dessa lista, resolvi fazer um exercício mais básico: pegar um componente que já existe, mudar alguma coisa nele, gerar um binário com a mudança, rodar os testes e ver o efeito acontecendo de verdade.

A ideia não é contribuir com isso. É provar que consigo fechar o ciclo. Enquanto eu não conseguisse fazer uma alteração chegar até um Collector rodando, qualquer estudo seria teórico.

Escolhi o filterprocessor e inventei uma tarefa sem valor nenhum para o projeto, mas útil para mim: quando aparecer um trace com service.name igual a trying, o processor deve registrar uma marca minha no log junto com o dado inteiro em JSON.

Passo 1: baixar só o que interessa #

O contrib é grande. Como cada componente é um módulo separado, dá para clonar só o pedaço necessário.

mkdir -p /tmp/otel-lab && cd /tmp/otel-lab
git clone --depth 1 --filter=blob:none --sparse \
  https://github.com/open-telemetry/opentelemetry-collector-contrib.git
cd opentelemetry-collector-contrib
git sparse-checkout set processor/filterprocessor internal pkg

Isso deixou 25 MB em disco em vez do repositório inteiro. Precisei incluir internal e pkg porque o go.mod do filterprocessor aponta para eles com caminhos relativos.

Passo 2: escrever a alteração #

Criei um arquivo novo, trying.go, em vez de espalhar código no traces.go. A função varre os resources procurando o atributo, e serializa o lote inteiro usando o marshaler que o próprio pdata oferece.

const tryingServiceName = "trying"

var tryingMarshaler = &ptrace.JSONMarshaler{}

func logTryingTraces(logger *zap.Logger, td ptrace.Traces) {
	if logger == nil || !hasTryingService(td) {
		return
	}
	raw, err := tryingMarshaler.MarshalTraces(td)
	if err != nil {
		logger.Error("apolzek was here: could not marshal traces", zap.Error(err))
		return
	}
	logger.Info("apolzek was here",
		zap.String("service.name", tryingServiceName),
		zap.Int("spans", td.SpanCount()),
		zap.String("payload", string(raw)),
	)
}

A chamada entrou no início de processTraces. Esse detalhe importa: a função tem um retorno antecipado logo abaixo, que sai na hora quando nenhuma condição de filtro está configurada. Se eu tivesse colocado a chamada depois dele, o teste manual não mostraria nada e eu passaria um tempo procurando erro no lugar errado.

func (fsp *filterSpanProcessor) processTraces(ctx context.Context, td ptrace.Traces) (ptrace.Traces, error) {
	logTryingTraces(fsp.logger, td)

	if fsp.skipResourceExpr == nil && fsp.skipSpanExpr == nil && ...

Passo 3: testar #

Escrevi um teste usando o observador de logs do zap, que captura o que foi registrado sem precisar ler saída de terminal. Cobri o caso que casa, um serviço diferente, um nome que só começa com trying, o nome vazio, o logger nulo e um service.name que não é string.

=== RUN   TestLogTryingTraces
=== RUN   TestLogTryingTraces/matches
=== RUN   TestLogTryingTraces/different_service
=== RUN   TestLogTryingTraces/prefix_only
=== RUN   TestLogTryingTraces/empty
--- PASS: TestLogTryingTraces (0.00s)
--- PASS: TestLogTryingTracesNilLogger (0.00s)
--- PASS: TestHasTryingServiceNonStringAttribute (0.00s)

Depois rodei a suíte inteira do componente, que é o que de fato responde se eu quebrei alguma coisa. Foram 82 testes, nenhuma falha.

go test ./...
ok  github.com/open-telemetry/opentelemetry-collector-contrib/processor/filterprocessor  1.319s

Passo 4: gerar um Collector com a alteração #

O binário oficial não serve, porque ele traz o filterprocessor publicado, não o meu. Para isso existe o OCB, o OpenTelemetry Collector Builder, que monta um Collector sob medida a partir de uma lista de componentes.

go install go.opentelemetry.io/collector/cmd/builder@v0.144.0

A parte central da configuração é o replaces, que manda o Go usar minha cópia local no lugar do módulo publicado.

dist:
  name: otelcol-lab
  output_path: /tmp/otel-lab/build/otelcol-lab
  otelcol_version: 0.144.0

receivers:
  - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.144.0
processors:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/filterprocessor v0.144.0
exporters:
  - gomod: go.opentelemetry.io/collector/exporter/debugexporter v0.144.0

replaces:
  - github.com/open-telemetry/opentelemetry-collector-contrib/processor/filterprocessor => /tmp/otel-lab/opentelemetry-collector-contrib/processor/filterprocessor

Antes de rodar, conferi se o binário realmente tinha meu código dentro, o que é mais rápido do que descobrir isso depois pelo silêncio no log:

strings otelcol-lab | grep -c "apolzek was here"
2

Saíram 35 MB de binário com um receiver, um processor e um exporter.

Passo 5: rodar e enviar dados #

Subi o Collector com um pipeline de traces mínimo, recebendo OTLP por HTTP e escrevendo no exportador de debug. Enviei dois traces por curl, iguais em tudo menos no service.name.

O primeiro, com service.name igual a checkout, para confirmar que a alteração não dispara em qualquer coisa:

HTTP 200
ocorrencias de 'apolzek was here' no log: 0

O segundo, com service.name igual a trying:

HTTP 200
ocorrencias de 'apolzek was here' no log: 1

A prova #

A linha que apareceu no log do Collector:

2026-08-25T14:32:47.120-0300  info  filterprocessor@v0.144.0/trying.go:47
apolzek was here
{"otelcol.component.id": "filter", "otelcol.component.kind": "processor",
 "otelcol.pipeline.id": "traces", "otelcol.signal": "traces",
 "service.name": "trying", "spans": 1,
 "payload": "{\"resourceSpans\":[{\"resource\":{\"attributes\":[{\"key\":\"service.name\",
 \"value\":{\"stringValue\":\"trying\"}},{\"key\":\"deployment.environment\",
 \"value\":{\"stringValue\":\"lab\"}}]},\"scopeSpans\":[{\"scope\":{\"name\":\"manual-curl\"},
 \"spans\":[{\"traceId\":\"5b8efff798038103d269b633813fc60c\",
 \"spanId\":\"eee19b7ec3c1b174\",\"name\":\"GET /checkout\",\"kind\":2,
 \"startTimeUnixNano\":\"1544712660000000000\",\"endTimeUnixNano\":\"1544712661000000000\",
 \"attributes\":[{\"key\":\"http.method\",\"value\":{\"stringValue\":\"GET\"}}],
 \"status\":{}}]}]}]}"}

Repare no caminho do arquivo no início da linha: filterprocessor@v0.144.0/trying.go:47. O componente se apresenta com a versão publicada, mas está executando o arquivo que eu criei. É o replaces funcionando.

O que ficou #

O ciclo fecha: editar, testar, gerar binário, rodar, ver acontecer. Isso é o que eu não sabia fazer na semana passada, e é pré-requisito para qualquer coisa útil depois.

Duas coisas me pegaram. A primeira foi o retorno antecipado no processTraces, que me faria depurar no lugar errado. A segunda foi perceber que testar exige montar um Collector próprio, porque o binário oficial nunca vai conter a minha alteração.

Nada disso vira pull request. É andaime, e andaime se descarta. Mas agora, quando eu pegar uma issue de verdade, a parte mecânica já está resolvida.

"only knowledge frees man" — E.C.