Skip to content

reporter: add OTLP/profiles as a third export format - #3205

Open
gnurizen wants to merge 7 commits into
mainfrom
otlp
Open

reporter: add OTLP/profiles as a third export format#3205
gnurizen wants to merge 7 commits into
mainfrom
otlp

Conversation

@gnurizen

@gnurizen gnurizen commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Adds OTLP/profiles as a third profile encoding alongside arrow v1 and v2,
selected with --remote-store-format=otlp. Verified end to end against two
independent OTLP/profiles receivers, both rendering symbolized flamegraphs.

Each commit builds and tests on its own:

  • reporter: extract the backend-independent parts of arrowReporter — pure
    refactor, no behavior change. The label pipeline, symbol uploader, metrics
    bridge and frame classifier move out of arrowReporter, and reporter.New
    takes a Config instead of 24 positional parameters. --remote-store-format
    supersedes the --use-v2-schema bool, with two values at this point.
  • reporter: add OTLP/profiles as a third export format — the encoder, plus
    --remote-store-compression and --debuginfo-address. The conversion is ours
    rather than upstream's, because pprofile.Profile carries one SampleType and
    upstream rejects the GPU origins before the encoding is reached.
  • main: add --symbolize-go — independent of the encoding. Resolves Go
    frames from gopclntab in-agent, for backends with no symbolizer service. Off
    by default.
  • reporter: only build the self-logs provider when logs are exported — the
    batch processor was running in every non-offline configuration regardless of
    --otlp-logging, allocating ~9% of the agent's total bytes.
  • reporter: benchmark both encoders against a null gRPC endpoint — replays
    a real .padata corpus through both paths so the encoders can be compared
    without eh_frame extraction swamping the result.
  • reporter: append a stack's location indices in one call — the largest
    single allocator in the OTLP path; −19% allocations.

On the same 20,000-sample corpus arrow v2 is ~1.6x cheaper in CPU and allocates
2.2x fewer objects, while OTLP is ~2.4x smaller on the wire. Both are a fraction
of a percent of a core at realistic sample rates.

Two known gaps, both on the store side rather than the agent: off-CPU is
wallclock on arrow and off_cpu on OTLP, which is a naming decision still to
be made; and OTLP has no field for aggregation temporality, so a consumer has to
derive delta from the sample type. Rationale for each change is in its commit
message.

@gnurizen
gnurizen force-pushed the otlp branch 4 times, most recently from b19c12b to 45ec8d5 Compare September 1, 2026 01:55
Groundwork for a second profile encoding. No behavior change; every existing
flag means what it did.

arrowReporter had grown to hold three separable concerns: the label pipeline,
the symbol uploader and metrics bridge, and the arrow encoding itself. Only the
last is specific to a wire format, so the other two move out where a second
backend can use them without either copying them or importing arrow.

  reporter/labeler.go      processLabeler: the label LRU, metadata providers,
                           relabel configs, external labels, and the three
                           disable flags. labelRetrievalResult now carries the
                           resource and sample label sets separately instead of
                           merging them, because a non-arrow encoding needs to
                           know which is which. The oomprof state it reads for
                           job="oomprof" becomes an atomic.Pointer, since it can
                           only be assigned after SetupWithReporter returns and
                           was previously written while readers ran.

  reporter/shared.go       execTracker, metricsBridge, reporterCounters, and the
                           logger()/tracer() accessors. reporterCounters holds
                           the format-neutral series; the four that describe
                           arrow write calls stay registered by New() so a
                           different backend does not publish permanently-zero
                           series.

  reporter/frames.go       classifyFrame, lifted out of appendLocationV2. It
                           returns a format-neutral description of a frame --
                           mapping file, build IDs, frame type, optional
                           function and line -- so an encoder consumes that
                           rather than reading libpf.Frame itself. The point is
                           that a fork bump changing frame semantics produces one
                           compile error instead of two silent divergences.

  reporter/config.go       Config, replacing reporter.New's 24 positional
                           parameters, plus newSharedReporterParts building the
                           pieces above the same way for any backend.

--remote-store-format supersedes --use-v2-schema. The bool was adequate for two
encodings and is not extensible; --use-v2-schema=false still maps onto
arrow-v1, so existing invocations are unaffected. The enum has two values here.

newLogProvider and newTracerProvider now take an exporter rather than a
*grpc.ClientConn, which is what lets a caller point the agent's own telemetry
somewhere other than the remote store.

Fixed while touching this code:

  The vtproto codec was registered as a side effect of the remote-store dial.
  It is a process-global replacement of gRPC's "proto" codec, so relying on
  dial order is fragile; RegisterProtoCodec() now runs before any dial.

  Tracer() dereferenced a nil provider despite its doc comment promising a
  no-op fallback. Reachable through offline mode plus a probe config.

  reporter.New's Start() error was discarded in main.go, and the arrow Start()
  leaked its reporting context when offline-mode MkdirAll failed.

The three labelsForTID tests move to reporter/labeler_test.go against
processLabeler and pass unchanged, which is the assertion that the extraction
preserved behavior.
Selected with --remote-store-format=otlp, alongside the existing arrow v1 and
v2 paths. Verified end to end against two independent OTLP/profiles receivers,
both rendering symbolized flamegraphs from the agent's output.

The conversion is ours rather than upstream's. Upstream's lives in
reporter/internal/pdata, which is unimportable, and no extension point would
have helped: pprofile.Profile carries exactly one SampleType, so a memory
profile with four value axes needs four Profiles, and oomprof never passes
through an origin at all; baseReporter also rejects the CUDA and GPU-PC origins
before the encoding is reached. Owning it means every origin the tracer
produces has a sample type, including GPU and memory.

reporter/pprofile.go builds one Profiles per flush with a request-global
dictionary, partitioning into Profiles by (sample type, unit, period type,
period) so GPU-PC's per-PID period lands on its own Profile. It reserves
mapping index 0 as the "no mapping" sentinel, because Location.MappingIndex has
no presence flag. Memory frames get a special case: ReportMemoryTraces stashes
the build ID in FunctionName and the executable path in SourceFile, which a
generic classifier would emit as a function literally named after a build ID.

Flags:

  --remote-store-format gains otlp. All three formats ride the existing
  remote-store connection, so OTLP inherits its TLS, auth, retry and client
  metrics rather than opening a second one.

  --remote-store-compression=gzip|none applies to the OTLP export only; the
  arrow paths compress inside their own IPC payload. gzip at level 1 rather
  than the level-6 default, since the CPU is spent on a node being profiled:
  level 1 runs at 192 MB/s against level 6's 108 MB/s for 6.6% of the ratio.
  gzip is the only choice because it is the only codec grpc-go ships on both
  sides; anything else fails at the first flush against a receiver that did
  not install it, which is not worth trading for a better ratio on a stream
  that runs at tens of KiB/s.

  --debuginfo-address points symbol upload at its own host, inheriting the
  remote store's credentials. Symbols travel the DebuginfoService protocol
  whatever encoding profiles use, so OTLP mode is not standalone, and flag
  validation says so rather than failing at the first upload.

Also fixed here:

  service.name was set only from APMServiceName, so a system-wide profiler
  sent anonymous resources for nearly everything and consumers keying on it
  named every process by a hash. Now falls back to comm, then the executable's
  basename.

  The remote store was dialed whenever the agent was not in offline mode, which
  was right when "not offline" implied "has a remote store".
  --remote-store-format=otlp with --debuginfo-upload-disable and no address is a
  third case, and it died on "received empty target in Build()". The dial is now
  gated on a non-empty address, which also makes the OTLP path's grpcConn == nil
  check reachable instead of dead.

Known gaps: OTLP has no field for aggregation temporality, so a consumer must
derive delta from the sample type; off-CPU is wallclock on arrow and off_cpu on
OTLP, which is still an open naming decision; and offline mode has no OTLP
representation and is rejected at flag validation.
The upstream Go interpreter reads gopclntab out of the binary and resolves
function, file and line in-agent. It was unconditionally disabled with no way
to turn it on, because Parca symbolizes native Go frames server-side from
uploaded debuginfo and doing it in both places pays for the same answer twice.

That reasoning holds only when the backend has a symbolizer. Against one that
does not, Go frames arrive as bare addresses and nothing later can name them,
since the agent is the only place gopclntab was available.

Off by default, so nothing changes for a Parca backend. On, it costs agent CPU
and memory per Go process and needs no debuginfo upload at all.

Independent of the profile encoding: it works the same on every
--remote-store-format.
newProviders built an sdklog.LoggerProvider whenever Config.GRPCConn was
non-nil, which is every non-offline run. --otlp-logging only controlled
whether main.go attached the logrus hook to the other end, so the usual
configuration ran a batch processor nothing wrote to and nothing read from.

It is not free. The processor allocates on its own timer regardless of
traffic, and an allocation profile over a 120s window put
slices.Clone[[]sdk/log.Record] at 9% of all bytes allocated by the agent --
140 to 156 MiB, roughly 1.3 MiB/s of garbage in a run that had logging
switched off.

Config gains ExportSelfLogs, set from --otlp-logging, and the logs branch
of newProviders is gated on it. With it false the provider stays nil,
Logger() hands out the OTel no-op it already falls back to, and
shutdownProviders skips it -- all three paths existed for the offline case.

Traces are deliberately untouched. A TracerProvider costs nothing until a
span is created, and the probe service needs the one it gets today.

Verified by running one binary both ways against an idle agent and diffing
allocation profiles over 60s: with --otlp-logging the clone appears at
65.6 MiB, 6.3% of bytes; without it, no log.Record allocation at all.
Whole-agent measurements could not answer which encoder is cheaper. Under a
realistic workload the agent's allocation profile is 45-53% eh_frame
extraction, so an encoder difference of any plausible size is inside the
noise, and the run-to-run variance in how many new executables a window
happens to discover is larger than the effect being looked for.

These benchmarks remove everything except the reporter. A .padata file named
by PARCA_BENCH_CORPUS is decoded back into the ReportTraceEvent arguments
that produced it, once, outside the timer; the benchmark replays them. With
no corpus set the benchmarks skip, so `go test ./...` stays hermetic.

The endpoint is a bufconn server with a raw codec and an unknown-service
handler: it accepts any method, decodes nothing, and replies with an empty
message. That detail is load-bearing. Registering the generated services
instead charges the benchmark for the receiver's unmarshal, which is wildly
asymmetric -- an arrow request is one bytes field, an OTLP request rebuilds
every Sample, Stack and Location in pdata. It inflated OTLP by 41%
(603,699 allocs/op against the 356,951 the agent actually pays) before the
codec was swapped in. The client side stays real: real codec, real
compression, real HTTP/2 framing.

Two pairs of benchmarks. Encode* is a whole flush interval, accumulate plus
serialize plus send. *Accumulate drops the flush, so the difference between
them is what serialization costs.

Reconstruction is faithful except for FileID: the arrow schema stores a
mapping as (filename, build ID) and keeps no FileID, so one is synthesized by
hashing that pair. The encoders use it as a dictionary key and as the htlhash
build-ID fallback, both of which only need stability and distinctness, so
cardinality is right even though the values are not real.
TestBenchCorpusSanity guards the decode -- an off-by-one in the ListView
offsets would produce empty stacks, which would benchmark nothing and look
fast doing it.
internStack appended one location index per frame. Every Append on an empty
Int32Slice regrows the backing array from zero, and at a mean stack depth of
33 that made pcommon.Int32Slice.Append the single largest allocator in the
whole OTLP encode path -- 1.44M objects, 12.3% of everything allocated during
accumulation, ahead of every pdata constructor.

Collect the indices in a scratch slice reused across stacks, size the
destination once, and append them together. The scratch is never aliased by
the dictionary, which copies on Append.

Measured with BenchmarkEncodeOTLP over a 20,000-sample corpus, 33.1 frames
per sample, 231 PIDs, 83 mappings:

  allocs/op   356,951 -> 289,561   (-19%)
  B/op         31.9MB ->  28.5MB   (-10%)
  ns/op         167.6 ->   159.1   (-5%)

It does not close the gap to arrow v2, which is still about 1.6x cheaper on
this corpus, but it is the largest single item and the cheapest to fix.
labelsForTID formats a CPU number and a thread ID on every sample, on the path
both backends take. fmt.Sprint boxes each argument into an interface and goes
through the reflection formatter; strconv.FormatUint does neither, and returns
a shared string with no allocation at all for values below 100.

Over 20,000 samples per iteration, 3 runs of 8: 30,140 allocations removed from
each backend, -10% for OTLP and -23% for arrow v2, which allocates less to
begin with. Wall time moves about 2%.

A lookup table for larger values was tried and removed: it measured identically,
because strconv already has the small-value fast path and thread IDs are mostly
too large for any table worth keeping.
@gnurizen
gnurizen marked this pull request as ready for review September 1, 2026 02:05
Comment thread flags/compression.go
case CompressionNone, "":
return nil, false, nil

case CompressionGzip:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the agent needs to work together with a collector and in-cluster network traffic spending the cpu is not necessary. Doesn't hurt to have this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I made a mental note to turn this off in our local testing rig and it should probably go in our docs somewhere that none is preferred if you are just sending profiles to a sidecar pod over loopback.

Comment thread reporter/pprofile.go

attrs := sample.AttributeIndices()
s.SampleLabels.Range(func(l labels.Label) {
switch l.Name {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these really the only 4 that semconv values exist for? Maybe it would be better if we integrated this mapping directly into the metadata providers directly so it's easier to maintain?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants