Skip to content

otlprelay: forward log records - #1800

Merged
Jeff Luo (JeffLuoo) merged 2 commits into
agent-substrate:mainfrom
baizhenyu:otlprelay-logs
Sep 23, 2026
Merged

Jeff Luo (JeffLuoo) merged 2 commits into
agent-substrate:mainfrom
baizhenyu:otlprelay-logs

Conversation

@baizhenyu

@baizhenyu Tim Bai (baizhenyu) commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Step 1 of #1748. The relay forwards LogsService the way it forwards traces and metrics: source gate, verbatim payload, atelet's own OTEL_EXPORTER_OTLP_LOGS_HEADERS upstream.

No content check on the records. Identity spoofing through the relay is handled in #1748 by the consumer rule (a lifecycle record counts only under ateapi's resource, which the source gate already enforces) and per-pod sockets (#741), not by a denylist here.

Endpoint and compression resolution (second commit, from review). The relay read the generic, traces, and metrics OTLP variables and ignored the logs ones, so a logs-specific collector or compression setting was silently overridden. All three signals now go through one resolver: each falls back to the generic, and every resolved value must agree, since the relay has one upstream connection. The conflict error names the variable each value came from; a signal that falls back names the generic variable, not its own unset one.

One behavior change: traces=A, metrics=A, generic=B with logs unset used to pass and now fails at atelet startup, because logs falls back to B and that is a two-collector configuration.

Tests: an ateom log batch arrives with the ateom's resource; the source gate, mixed-batch, header-isolation, and per-signal header tests cover logs; resolver tables gained logs rows including the fallback case. Docs say the relay carries logs, traces, and metrics.

The relay carried traces and metrics. ateom is about to emit log
records over it, so it forwards LogsService the same way: source
gate, verbatim payload, atelet's own headers upstream.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Added some questions/comments!

headersEnv = "OTEL_EXPORTER_OTLP_HEADERS"
tracesHeadersEnv = "OTEL_EXPORTER_OTLP_TRACES_HEADERS"
metricsHeadersEnv = "OTEL_EXPORTER_OTLP_METRICS_HEADERS"
logsHeadersEnv = "OTEL_EXPORTER_OTLP_LOGS_HEADERS"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What happens if atelet also sets OTEL_EXPORTER_OTLP_LOGS_ENDPOINT? I think upstreamTarget() (L548) only reads the generic, traces and metrics variables, so logs would quietly go to the traces/metrics collector. Same question for LOGS_COMPRESSION in upstreamCompression() (L508).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right, both resolvers ignored the logs variable, so logs would have followed the traces/metrics collector silently. Both now read it and apply the same conflict rule, and the error names the variables the two values came from, since a fallback pulls the generic in under a signal that is not itself set. One behavior change falls out: a config that sets the traces and metrics overrides to one collector and leaves logs unset now fails at startup, because logs falls back to the generic and that is a two-collector configuration. The old test row asserting the lenient result is updated and a new row pins the refusal.

Comment thread internal/otlprelay/relay.go Outdated
@@ -97,6 +98,7 @@ const (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// traces, metrics, and logs may legitimately differ and are resolved separately.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Taken.

Comment thread internal/otlprelay/relay.go Outdated
Comment on lines 302 to 304

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

logRelay shares the gate too now, want to update this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Updated.

Comment on lines +444 to +448
collogspb.RegisterLogsServiceServer(s.grpc, &logRelay{
upstream: collogspb.NewLogsServiceClient(upstream),
headers: logHeaders,
gate: gate,
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just a forward thinking question for later steps: a new ateom that talks to an old atelet gets Unimplemented on logs. The SDK drops that batch without retrying, and Dial won't fall back because the socket exists I think. How were you planning for RelayCapable to handle that?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It won't, and we don't plan to make it. An old atelet returns Unimplemented, the SDK doesn't retry it, and Dial only falls back when the socket is missing, so a new ateom on an old atelet loses its log batches for the rollout window. We accept that: the project is early and doesn't carry backward compatibility across a version skew (#1644 broke the discovery response shape in place the same way, and #809 landed the relay with the same atelet-before-ateom ordering). The window is bounded, self-heals, and costs telemetry, not the actor; RelayCapable stays a resource attribute.

logs []*collogspb.ExportLogsServiceRequest
traceMD []metadata.MD
metricMD []metadata.MD
logMD []metadata.MD

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are you planning to extend exportBoth and TestExportAttachesAteletHeaders with a logs coverage? That's the path where we most want proof that an ateom can't pick atelet's credentials.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes. exportBoth is now exportAll with a log export, TestExportDropsClientMetadata checks the log leg too, and TestExportAttachesAteletHeaders sets OTEL_EXPORTER_OTLP_LOGS_HEADERS and asserts the log export carries it and nothing of the client's.

Comment thread internal/otlprelay/relay_test.go Outdated
}
}

func TestRelayRefusesNonAteomLogs(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wdyt about doing this as a third Export in the TestRelayRefusesNonAteomSource table instead? Then logs also get the empty service.name case for free. Same idea for TestRelayAcceptsEveryAteomService and TestRelayRefusesMixedBatch.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done for all three; the separate logs test is gone, and the empty service.name case now covers logs.

Comment on lines +98 to +107
func (l *logsSink) Export(ctx context.Context, req *collogspb.ExportLogsServiceRequest) (*collogspb.ExportLogsServiceResponse, error) {
l.parent.mu.Lock()
l.parent.logs = append(l.parent.logs, req)
if md, ok := metadata.FromIncomingContext(ctx); ok {
l.parent.logMD = append(l.parent.logMD, md.Copy())
}
l.parent.mu.Unlock()
l.parent.got <- struct{}{}
return &collogspb.ExportLogsServiceResponse{}, nil
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If you add a LOGS_ENDPOINT conflict check, would a logs row in TestUpstreamTarget be the natural place to check this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes; three logs rows there (agrees, conflicts with generic, conflicts with traces) plus the row where logs is unset and falls back to the generic, and two in the compression table.

Comment on lines +444 to +445
carries logs, traces, and metrics, so a worker pod exports log records the same
way it exports the other two signals.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Technically, worker pods will only export logs later, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right. Split into two facts: the relay carries logs, traces, and metrics; worker pods do not emit log records yet.

The relay read the generic, traces, and metrics OTLP variables and
ignored the logs ones, so a logs-specific collector or compression
setting was silently overridden by whatever traces and metrics
resolved to. All three signals now go through one resolver that
requires every resolved value to agree, and its conflict error names
the variable each value came from, since a signal that falls back to
the generic pulls it in under a variable that is not itself set.

Also extends the source-gate, header, and mixed-batch tests to the log
path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This LGTM, can you please update the PR description to reflect the new changes?

@baizhenyu

Copy link
Copy Markdown
Collaborator Author

This LGTM, can you please update the PR description to reflect the new changes?

Thanks! Description updated.

@JeffLuoo
Jeff Luo (JeffLuoo) added this pull request to the merge queue Sep 23, 2026
Merged via the queue into agent-substrate:main with commit 35dde2e Sep 23, 2026
11 checks passed
Bowei Du (bowei) pushed a commit to bowei/agent-substrate that referenced this pull request Sep 24, 2026
…bstrate#1857)

Step 2 of agent-substrate#1748, plus the consumer rule from step 1b.

`LoggingOptions` gains `ExporterConn` and `RelayCapable`, mirroring
`TracingOptions`. Both ateoms initialize logging after metrics with the
relay connection and defer a nil-guarded shutdown. `OTEL_LOGS_EXPORTER`
still defaults to `none`, so no ateom emits a record; this is the
provider the actor events land on when emission moves.

A relay-capable log resource carries `ate.otlp.relay` like traces and
metrics do. `newLoggerProvider` is split from `InitLogging` so the test
asserts that on an emitted record's resource, the way
`TestMeterProviderRelayAttribute` does for metrics.

`docs/observability.md` states the consumer rule that replaced the relay
denylist dropped in agent-substrate#1800: a lifecycle record is authoritative only
under ateapi's resource, because the relay admits only ateom resources.

Follow-up for the emission step, not here: the controller does not
inject `OTEL_LOGS_EXPORTER` into worker pods, so no environment
exercises log-over-relay yet.
@bowei Bowei Du (bowei) added area/node kind/feature An enhancement / feature request or implementation labels Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/node area/observability kind/feature An enhancement / feature request or implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants