Files
68ce98920a feat(zap): native ZAP-transport OTLP receiver + exporter (#4)
* feat(zap): native ZAP-transport OTLP receiver + exporter

Adds ZAP-native telemetry ingestion/forwarding over github.com/zap-proto/http
(the Hanzo ZAP wire — capnp frames, X-Wing PQ-KEM handshake), NOT OTLP-HTTP
(:4318) or OTLP-gRPC (:4317). The payload stays standard OTLP protobuf so full
OTel fidelity + pipeline interop are preserved; only the transport is ZAP.

- receiver/zapreceiver: runs a zaphttp.Server on :4319, terminates OTLP over
  the ZAP wire on /v1/{traces,logs,metrics}, decodes to pdata, pushes to the
  pipeline (-> ClickHouse exporters -> hanzoai/datastore). ObsReport transport
  label is "zap". One listener serves all three signals.
- exporter/zapexporter: pdata -> OTLP proto -> zaphttp POST to a peer collector's
  ZAP receiver (used by the filelog log-agent to ship node logs over ZAP).
- Registered in components.go alongside otlpreceiver (OTLP retained as an
  interop endpoint for third-party SDKs).

Tests: TestZapReceiverAllSignals (traces+logs+metrics e2e over the real ZAP
wire) and TestExportTracesOverZAP both green; full ./components build clean.

* build(zap): self-building multi-stage Dockerfile for in-cluster Kaniko/BuildKit

The default Dockerfile COPYs a CI-prebuilt binary; this compiles in-image so an
in-cluster BuildKit/Kaniko job (platform.hanzo.ai) can produce the ZAP-enabled
image without the (currently broken) GitHub reusable workflow.

---------

Co-authored-by: hanzo-dev <[email protected]>
2026-07-03 14:56:33 -07:00

250 lines
7.4 KiB
Go

package zapreceiver
import (
"bytes"
"context"
"fmt"
"net"
"sync"
"github.com/valyala/fasthttp"
zaphttp "github.com/zap-proto/http"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/component/componentstatus"
"go.opentelemetry.io/collector/consumer"
"go.opentelemetry.io/collector/pdata/plog/plogotlp"
"go.opentelemetry.io/collector/pdata/pmetric/pmetricotlp"
"go.opentelemetry.io/collector/pdata/ptrace/ptraceotlp"
"go.opentelemetry.io/collector/receiver"
"go.opentelemetry.io/collector/receiver/receiverhelper"
"go.uber.org/zap"
)
// transport is the ObsReport transport label — proof, in the collector's own
// self-telemetry, that these signals arrived over ZAP and not http/grpc.
const transport = "zap"
const (
pathTraces = "/v1/traces"
pathLogs = "/v1/logs"
pathMetrics = "/v1/metrics"
)
// zapReceiver terminates OTLP payloads carried over the ZAP-HTTP wire.
type zapReceiver struct {
settings receiver.Settings
cfg *Config
traces consumer.Traces
logs consumer.Logs
metrics consumer.Metrics
obsT *receiverhelper.ObsReport
obsL *receiverhelper.ObsReport
obsM *receiverhelper.ObsReport
mu sync.Mutex
srv *zaphttp.Server
addr string
wg sync.WaitGroup
}
func newReceiver(set receiver.Settings, cfg *Config) (*zapReceiver, error) {
mkObs := func() (*receiverhelper.ObsReport, error) {
return receiverhelper.NewObsReport(receiverhelper.ObsReportSettings{
ReceiverID: set.ID,
Transport: transport,
ReceiverCreateSettings: set,
})
}
obsT, err := mkObs()
if err != nil {
return nil, err
}
obsL, err := mkObs()
if err != nil {
return nil, err
}
obsM, err := mkObs()
if err != nil {
return nil, err
}
return &zapReceiver{settings: set, cfg: cfg, obsT: obsT, obsL: obsL, obsM: obsM}, nil
}
// Start binds the ZAP-HTTP listener and serves the OTLP-over-ZAP handler.
func (r *zapReceiver) Start(_ context.Context, host component.Host) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.srv != nil {
return nil
}
ln, err := net.Listen("tcp", r.cfg.Endpoint)
if err != nil {
return fmt.Errorf("zapreceiver: listen %s: %w", r.cfg.Endpoint, err)
}
r.addr = ln.Addr().String()
r.srv = &zaphttp.Server{Handler: r.handle}
r.wg.Add(1)
go func() {
defer r.wg.Done()
if serveErr := r.srv.Serve(ln); serveErr != nil {
r.settings.Logger.Error("zapreceiver serve stopped", zap.Error(serveErr))
componentstatus.ReportStatus(host, componentstatus.NewFatalErrorEvent(serveErr))
}
}()
r.settings.Logger.Info("ZAP-native OTLP receiver started (wire=zap, not http/grpc)",
zap.String("endpoint", r.addr))
return nil
}
// Shutdown stops the ZAP-HTTP listener.
func (r *zapReceiver) Shutdown(context.Context) error {
r.mu.Lock()
srv := r.srv
r.mu.Unlock()
if srv == nil {
return nil
}
err := srv.Close()
r.wg.Wait()
return err
}
// addrForTest exposes the resolved listen address (used by tests when the
// configured endpoint is :0).
func (r *zapReceiver) addrForTest() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.addr
}
// handle is the fasthttp.RequestHandler dispatched by zaphttp for each ZAP
// frame. It routes the standard OTLP/HTTP signal paths.
func (r *zapReceiver) handle(ctx *fasthttp.RequestCtx) {
if !ctx.IsPost() {
reject(ctx, fasthttp.StatusMethodNotAllowed, "zapreceiver: POST required")
return
}
switch string(ctx.Path()) {
case pathTraces:
r.handleTraces(ctx)
case pathLogs:
r.handleLogs(ctx)
case pathMetrics:
r.handleMetrics(ctx)
default:
reject(ctx, fasthttp.StatusNotFound, "zapreceiver: unknown path "+string(ctx.Path()))
}
}
func isJSON(ctx *fasthttp.RequestCtx) bool {
return bytes.Contains(ctx.Request.Header.ContentType(), []byte("json"))
}
func (r *zapReceiver) handleTraces(ctx *fasthttp.RequestCtx) {
if r.traces == nil {
reject(ctx, fasthttp.StatusNotFound, "zapreceiver: traces pipeline not configured")
return
}
req := ptraceotlp.NewExportRequest()
if err := unmarshal(ctx, func(b []byte) error { return req.UnmarshalProto(b) }, func(b []byte) error { return req.UnmarshalJSON(b) }); err != nil {
reject(ctx, fasthttp.StatusBadRequest, "zapreceiver: decode traces: "+err.Error())
return
}
td := req.Traces()
octx := r.obsT.StartTracesOp(context.Background())
n := td.SpanCount()
cErr := r.traces.ConsumeTraces(octx, td)
r.obsT.EndTracesOp(octx, transport, n, cErr)
if cErr != nil {
reject(ctx, fasthttp.StatusInternalServerError, "zapreceiver: consume traces: "+cErr.Error())
return
}
resp := ptraceotlp.NewExportResponse()
writeResponse(ctx, func() ([]byte, error) { return resp.MarshalProto() }, func() ([]byte, error) { return resp.MarshalJSON() })
}
func (r *zapReceiver) handleLogs(ctx *fasthttp.RequestCtx) {
if r.logs == nil {
reject(ctx, fasthttp.StatusNotFound, "zapreceiver: logs pipeline not configured")
return
}
req := plogotlp.NewExportRequest()
if err := unmarshal(ctx, func(b []byte) error { return req.UnmarshalProto(b) }, func(b []byte) error { return req.UnmarshalJSON(b) }); err != nil {
reject(ctx, fasthttp.StatusBadRequest, "zapreceiver: decode logs: "+err.Error())
return
}
ld := req.Logs()
octx := r.obsL.StartLogsOp(context.Background())
n := ld.LogRecordCount()
cErr := r.logs.ConsumeLogs(octx, ld)
r.obsL.EndLogsOp(octx, transport, n, cErr)
if cErr != nil {
reject(ctx, fasthttp.StatusInternalServerError, "zapreceiver: consume logs: "+cErr.Error())
return
}
resp := plogotlp.NewExportResponse()
writeResponse(ctx, func() ([]byte, error) { return resp.MarshalProto() }, func() ([]byte, error) { return resp.MarshalJSON() })
}
func (r *zapReceiver) handleMetrics(ctx *fasthttp.RequestCtx) {
if r.metrics == nil {
reject(ctx, fasthttp.StatusNotFound, "zapreceiver: metrics pipeline not configured")
return
}
req := pmetricotlp.NewExportRequest()
if err := unmarshal(ctx, func(b []byte) error { return req.UnmarshalProto(b) }, func(b []byte) error { return req.UnmarshalJSON(b) }); err != nil {
reject(ctx, fasthttp.StatusBadRequest, "zapreceiver: decode metrics: "+err.Error())
return
}
md := req.Metrics()
octx := r.obsM.StartMetricsOp(context.Background())
n := md.DataPointCount()
cErr := r.metrics.ConsumeMetrics(octx, md)
r.obsM.EndMetricsOp(octx, transport, n, cErr)
if cErr != nil {
reject(ctx, fasthttp.StatusInternalServerError, "zapreceiver: consume metrics: "+cErr.Error())
return
}
resp := pmetricotlp.NewExportResponse()
writeResponse(ctx, func() ([]byte, error) { return resp.MarshalProto() }, func() ([]byte, error) { return resp.MarshalJSON() })
}
// unmarshal decodes the request body as OTLP protobuf (default) or JSON.
func unmarshal(ctx *fasthttp.RequestCtx, proto func([]byte) error, jsn func([]byte) error) error {
body := ctx.PostBody()
if isJSON(ctx) {
return jsn(body)
}
return proto(body)
}
// writeResponse emits the OTLP ExportResponse in the request's encoding.
func writeResponse(ctx *fasthttp.RequestCtx, proto func() ([]byte, error), jsn func() ([]byte, error)) {
var (
out []byte
err error
)
if isJSON(ctx) {
out, err = jsn()
ctx.SetContentType("application/json")
} else {
out, err = proto()
ctx.SetContentType("application/x-protobuf")
}
if err != nil {
reject(ctx, fasthttp.StatusInternalServerError, "zapreceiver: marshal response: "+err.Error())
return
}
ctx.SetStatusCode(fasthttp.StatusOK)
ctx.SetBody(out)
}
func reject(ctx *fasthttp.RequestCtx, status int, msg string) {
ctx.SetStatusCode(status)
ctx.SetContentType("text/plain; charset=utf-8")
ctx.SetBodyString(msg)
}