Skip to content

Commit 27e4a34

Browse files
committed
reporter: add ParcaReporter log-event interface and per-sample relabel
Introduce a reporter.ParcaReporter interface that extends otel's TraceReporter with ReportLogEvents([]LogEvent) error so any producer (uprobes or otherwise) can ship OTLP logs through the reporter without owning its own bidi stream. The existing struct is renamed arrowReporter and now owns a logStreamer: a channel-backed goroutine that batches LogEvents and pushes them on the ArrowLogsService bidi stream. Constructed only when a gRPC conn is provided, so offline mode is a no-op. LogEvent uses a generic shape (Body + Attributes map) so non-uprobe producers can reuse the interface. Also fold in per-sample relabeling for probe-origin trace samples: labelsForTID now runs a second relabel.ProcessBuilder pass against the patched per-sample labels (thread_id, thread_name, cpu) when meta.Origin is TraceOriginProbe, so relabel rules can derive custom labels from per-sample fields without touching the cached per-PID fast path used by CPU/off-CPU/memory/cuda samples.
1 parent 3a39bfe commit 27e4a34

8 files changed

Lines changed: 413 additions & 62 deletions

File tree

flags/codec.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,52 @@ type vtprotoMessage interface {
3636
UnmarshalVT(data []byte) error
3737
}
3838

39+
// pdataProtoMarshaler is the marshalling shape implemented by proto types
40+
// generated under go.opentelemetry.io/collector/pdata/internal (used by the
41+
// plogotlp / pmetricotlp / ptraceotlp gRPC clients). They don't satisfy any
42+
// of the proto.Message variants above — they expose pdata's own custom
43+
// SizeProto/MarshalProto pair, which writes a pre-sized buffer in reverse.
44+
type pdataProtoMarshaler interface {
45+
SizeProto() int
46+
MarshalProto([]byte) int
47+
}
48+
49+
// pdataProtoUnmarshaler is the receive-side counterpart of
50+
// pdataProtoMarshaler. Server-side handlers / streaming readers go through
51+
// this path.
52+
type pdataProtoUnmarshaler interface {
53+
UnmarshalProto(data []byte) error
54+
}
55+
3956
func (vtprotoCodec) Marshal(v any) ([]byte, error) {
4057
switch v := v.(type) {
4158
case vtprotoMessage:
4259
return v.MarshalVT()
60+
case pdataProtoMarshaler:
61+
buf := make([]byte, v.SizeProto())
62+
_ = v.MarshalProto(buf)
63+
return buf, nil
4364
case proto.Message:
4465
return proto.Marshal(v)
4566
case gogoproto.Message:
4667
return gogoproto.Marshal(v)
4768
default:
48-
return nil, fmt.Errorf("failed to marshal, message is %T, must satisfy the vtprotoMessage interface or want proto.Message, gogoproto.Message", v)
69+
return nil, fmt.Errorf("failed to marshal, message is %T, must satisfy the vtprotoMessage interface or want proto.Message, gogoproto.Message, or pdata SizeProto/MarshalProto", v)
4970
}
5071
}
5172

5273
func (vtprotoCodec) Unmarshal(data []byte, v any) error {
5374
switch v := v.(type) {
5475
case vtprotoMessage:
5576
return v.UnmarshalVT(data)
77+
case pdataProtoUnmarshaler:
78+
return v.UnmarshalProto(data)
5679
case proto.Message:
5780
return proto.Unmarshal(data, v)
5881
case gogoproto.Message:
5982
return gogoproto.Unmarshal(data, v)
6083
default:
61-
return fmt.Errorf("failed to unmarshal, message is %T, must satisfy the vtprotoMessage interface or want proto.Message, gogoproto.Message", v)
84+
return fmt.Errorf("failed to unmarshal, message is %T, must satisfy the vtprotoMessage interface or want proto.Message, gogoproto.Message, or pdata UnmarshalProto", v)
6285
}
6386
}
6487

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ require (
3333
github.com/xyproto/ainur v1.3.3
3434
github.com/zcalusic/sysinfo v1.1.3
3535
github.com/zeebo/xxh3 v1.1.0
36+
go.opentelemetry.io/collector/pdata v1.55.0
3637
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0
3738
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0
3839
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0
@@ -91,7 +92,7 @@ require (
9192
github.com/elastic/go-perf v0.0.0-20260224073651-af0ee0c731b7 // indirect
9293
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
9394
github.com/felixge/httpsnoop v1.0.4 // indirect
94-
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
95+
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
9596
github.com/gnurizen/sass-table v0.0.3 // indirect
9697
github.com/go-logr/logr v1.4.3 // indirect
9798
github.com/go-logr/stdr v1.2.2 // indirect
@@ -150,12 +151,12 @@ require (
150151
github.com/spf13/cobra v1.9.1 // indirect
151152
github.com/spf13/pflag v1.0.7 // indirect
152153
github.com/x448/float16 v0.8.4 // indirect
154+
github.com/zeebo/assert v1.3.1 // indirect
153155
go.opencensus.io v0.24.0 // indirect
154156
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
155157
go.opentelemetry.io/collector/consumer v1.55.0 // indirect
156158
go.opentelemetry.io/collector/consumer/xconsumer v0.149.0 // indirect
157159
go.opentelemetry.io/collector/featuregate v1.55.0 // indirect
158-
go.opentelemetry.io/collector/pdata v1.55.0 // indirect
159160
go.opentelemetry.io/collector/pdata/pprofile v0.149.0 // indirect
160161
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
161162
go.uber.org/multierr v1.11.0 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
126126
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
127127
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
128128
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
129-
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
130-
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
129+
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
130+
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
131131
github.com/gnurizen/sass-table v0.0.3 h1:nM0xZLrgnE2rdEcUEh8qvwKnVoJqp9XfbqEerXCHmYY=
132132
github.com/gnurizen/sass-table v0.0.3/go.mod h1:GIGuevLlAap2gS2gHeecoUTMp7t5RfUuvt19BMIAfpE=
133133
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -382,8 +382,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
382382
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
383383
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
384384
github.com/zcalusic/sysinfo v1.1.3/go.mod h1:NX+qYnWGtJVPV0yWldff9uppNKU4h40hJIRPf/pGLv4=
385-
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
386-
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
385+
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
386+
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
387387
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
388388
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
389389
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ func mainWithExitCode() flags.ExitCode {
427427
f.Metadata.DisableThreadCommLabel,
428428
f.RemoteStore.UseV2Schema,
429429
f.MergeGpuProfiles,
430+
grpcConn,
430431
)
431432
if err != nil {
432433
return flags.Failure("Failed to start reporting: %v", err)

reporter/iface.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2026 The Parca Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package reporter
15+
16+
import (
17+
"go.opentelemetry.io/ebpf-profiler/reporter"
18+
)
19+
20+
// ParcaReporter is the parca-agent reporter API: it accepts both profile
21+
// trace events (via the embedded TraceReporter) and OTLP log events. Consumers
22+
// that only need to publish logs (e.g. the probes BPF service, or any other
23+
// non-uprobe producer) should depend on this interface rather than the
24+
// concrete implementation, so they remain independent of profile-side code.
25+
type ParcaReporter interface {
26+
reporter.TraceReporter
27+
28+
// ReportLogEvents enqueues a batch of LogEvents for the Arrow log
29+
// streamer to ship. Returns nil on success. Drops on a saturated queue
30+
// are accounted for via the implementation's queue-drop counter and do
31+
// not return an error — callers are not expected to retry.
32+
ReportLogEvents(events []LogEvent) error
33+
}

reporter/log_streamer.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright 2026 The Parca Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package reporter
15+
16+
import (
17+
"context"
18+
"fmt"
19+
"sync/atomic"
20+
"time"
21+
22+
log "github.com/sirupsen/logrus"
23+
"go.opentelemetry.io/collector/pdata/pcommon"
24+
"go.opentelemetry.io/collector/pdata/plog"
25+
"go.opentelemetry.io/collector/pdata/plog/plogotlp"
26+
"google.golang.org/grpc"
27+
)
28+
29+
// LogEvent is the in-process representation of a single OTLP log record
30+
// produced by any source that uses the ParcaReporter log-event API. The
31+
// streamer batches a slice of these and ships them as one OTLP/gRPC
32+
// ExportLogsServiceRequest.
33+
type LogEvent struct {
34+
TimestampNs int64 // wall-clock ns (unix epoch) of the event itself
35+
ObservedTimestampNs int64 // wall-clock ns at the moment the producer enqueued the event
36+
Body string // LogRecord.Body (set as a string body)
37+
Attributes map[string]LogAttr
38+
}
39+
40+
// LogAttr is a tagged union covering the OTLP attribute value types we use.
41+
// Producers populate one of Str / Int and leave the other zero. The streamer
42+
// picks the right setter based on which is set.
43+
type LogAttr struct {
44+
Str string
45+
Int int64
46+
IsInt bool
47+
}
48+
49+
const (
50+
logStreamerBatchSize = 512
51+
logStreamerBatchAge = 250 * time.Millisecond
52+
logStreamerQueueSize = 4096
53+
logStreamerErrorBackoff = 5 * time.Second
54+
logStreamerScopeName = "parca-agent"
55+
)
56+
57+
// logStreamerOptions is the resource-attribute payload attached to every batch.
58+
type logStreamerOptions struct {
59+
ServiceName string // service.name = "parca-agent"
60+
ServiceVersion string // service.version = build VCS revision
61+
HostName string // host.name = agent --node
62+
}
63+
64+
// logStreamer batches LogEvents and ships them as OTLP/gRPC
65+
// ExportLogsServiceRequest messages via plogotlp.GRPCClient. Owned by
66+
// arrowReporter; constructed once per New() and run in the Start() goroutine
67+
// when grpcConn is non-nil.
68+
type logStreamer struct {
69+
conn *grpc.ClientConn
70+
client plogotlp.GRPCClient
71+
opts logStreamerOptions
72+
73+
in chan LogEvent
74+
75+
// Counters surfaced via prometheus from arrowReporter; the streamer itself
76+
// only owns the atomics. arrowReporter wires them into a registry.
77+
batchesSent atomic.Uint64
78+
eventsSent atomic.Uint64
79+
exportErrs atomic.Uint64
80+
queueDrops atomic.Uint64
81+
rejected atomic.Uint64
82+
}
83+
84+
func newLogStreamer(conn *grpc.ClientConn, opts logStreamerOptions) *logStreamer {
85+
return &logStreamer{
86+
conn: conn,
87+
client: plogotlp.NewGRPCClient(conn),
88+
opts: opts,
89+
in: make(chan LogEvent, logStreamerQueueSize),
90+
}
91+
}
92+
93+
// enqueue tries to publish a single event. Returns false if the queue is full;
94+
// the caller (ReportLogEvents) increments queueDrops and moves on.
95+
func (s *logStreamer) enqueue(ev LogEvent) bool {
96+
select {
97+
case s.in <- ev:
98+
return true
99+
default:
100+
s.queueDrops.Add(1)
101+
return false
102+
}
103+
}
104+
105+
// run batches LogEvents and ships them as OTLP ExportLogsServiceRequest
106+
// messages. Each batch is one unary RPC; transient backend errors trigger a
107+
// brief sleep to avoid hot-looping on persistent failures. Returns when ctx is
108+
// cancelled.
109+
func (s *logStreamer) run(ctx context.Context) {
110+
batch := make([]LogEvent, 0, logStreamerBatchSize)
111+
flushTimer := time.NewTimer(logStreamerBatchAge)
112+
defer flushTimer.Stop()
113+
stopLogFlushTimer(flushTimer)
114+
115+
flush := func() {
116+
if len(batch) == 0 {
117+
return
118+
}
119+
if err := s.export(ctx, batch); err != nil {
120+
if ctx.Err() != nil {
121+
return
122+
}
123+
s.exportErrs.Add(1)
124+
log.Warnf("log streamer: export errored (dropping %d events, backing off %s): %v",
125+
len(batch), logStreamerErrorBackoff, err)
126+
// Backoff to avoid spinning on a persistently-broken endpoint.
127+
// Events accumulating during the sleep are queued in s.in and may
128+
// also be dropped by enqueue's non-blocking send (queueDrops).
129+
select {
130+
case <-ctx.Done():
131+
case <-time.After(logStreamerErrorBackoff):
132+
}
133+
} else {
134+
s.batchesSent.Add(1)
135+
s.eventsSent.Add(uint64(len(batch)))
136+
}
137+
batch = batch[:0]
138+
}
139+
140+
for {
141+
select {
142+
case <-ctx.Done():
143+
flush()
144+
return
145+
146+
case ev, ok := <-s.in:
147+
if !ok {
148+
flush()
149+
return
150+
}
151+
if len(batch) == 0 {
152+
resetLogFlushTimer(flushTimer, logStreamerBatchAge)
153+
}
154+
batch = append(batch, ev)
155+
if len(batch) >= logStreamerBatchSize {
156+
flush()
157+
stopLogFlushTimer(flushTimer)
158+
}
159+
160+
case <-flushTimer.C:
161+
flush()
162+
}
163+
}
164+
}
165+
166+
// export ships one batch as a single OTLP/gRPC ExportLogsServiceRequest. The
167+
// returned error means the RPC itself failed; a successful RPC with
168+
// PartialSuccess.RejectedLogRecords > 0 is logged but not returned (the rest of
169+
// the batch was accepted).
170+
func (s *logStreamer) export(ctx context.Context, batch []LogEvent) error {
171+
req := plogotlp.NewExportRequestFromLogs(s.buildLogs(batch))
172+
resp, err := s.client.Export(ctx, req)
173+
if err != nil {
174+
return fmt.Errorf("plogotlp export: %w", err)
175+
}
176+
if ps := resp.PartialSuccess(); ps.RejectedLogRecords() > 0 {
177+
s.rejected.Add(uint64(ps.RejectedLogRecords()))
178+
log.Warnf("log streamer: server rejected %d/%d records: %s",
179+
ps.RejectedLogRecords(), len(batch), ps.ErrorMessage())
180+
}
181+
return nil
182+
}
183+
184+
func (s *logStreamer) buildLogs(batch []LogEvent) plog.Logs {
185+
logs := plog.NewLogs()
186+
rl := logs.ResourceLogs().AppendEmpty()
187+
resAttr := rl.Resource().Attributes()
188+
resAttr.PutStr("service.name", s.opts.ServiceName)
189+
if s.opts.ServiceVersion != "" {
190+
resAttr.PutStr("service.version", s.opts.ServiceVersion)
191+
}
192+
if s.opts.HostName != "" {
193+
resAttr.PutStr("host.name", s.opts.HostName)
194+
}
195+
196+
sl := rl.ScopeLogs().AppendEmpty()
197+
sl.Scope().SetName(logStreamerScopeName)
198+
199+
records := sl.LogRecords()
200+
records.EnsureCapacity(len(batch))
201+
for _, ev := range batch {
202+
lr := records.AppendEmpty()
203+
lr.SetTimestamp(pcommon.Timestamp(ev.TimestampNs))
204+
lr.SetObservedTimestamp(pcommon.Timestamp(ev.ObservedTimestampNs))
205+
lr.Body().SetStr(ev.Body)
206+
a := lr.Attributes()
207+
for k, v := range ev.Attributes {
208+
if v.IsInt {
209+
a.PutInt(k, v.Int)
210+
} else {
211+
a.PutStr(k, v.Str)
212+
}
213+
}
214+
}
215+
216+
return logs
217+
}
218+
219+
// stopLogFlushTimer drains the timer channel after Stop so the next Reset
220+
// starts cleanly.
221+
func stopLogFlushTimer(t *time.Timer) {
222+
if !t.Stop() {
223+
select {
224+
case <-t.C:
225+
default:
226+
}
227+
}
228+
}
229+
230+
func resetLogFlushTimer(t *time.Timer, d time.Duration) {
231+
stopLogFlushTimer(t)
232+
t.Reset(d)
233+
}

0 commit comments

Comments
 (0)