Skip to content

[LOGS] Pass resolved context to LogRecordProcessor::OnEmit unconditionally - #4421

Open
om7057 wants to merge 9 commits into
open-telemetry:mainfrom
om7057:logs/unconditional-resolved-context
Open

[LOGS] Pass resolved context to LogRecordProcessor::OnEmit unconditionally#4421
om7057 wants to merge 9 commits into
open-telemetry:mainfrom
om7057:logs/unconditional-resolved-context

Conversation

@om7057

@om7057 om7057 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Split out of #4309 per review: EventToSpanEventBridgeProcessor needs the resolved trace Context at OnEmit time to reach the live Span it bridges an event onto. That turned out to be a public API surface for both logs::Logger and sdk::logs::LogRecordProcessor, so it gets its own PR with its own analysis before the bridge processor depends on it. #4309 will be rebased on top of this once it merges, and cut down to just the bridge processor + declarative config.

The bug: Logger::EmitLogRecord(record) re-derived the resolved context from RuntimeContext::GetCurrent() (ambient) instead of reusing whatever context had already been resolved for this record, so an explicitly supplied context could be silently dropped in favor of whatever span happened to be ambient at emit time.

Design: resolve context once and always pass it to OnEmit, no per-processor opt-in flag (an earlier draft of this had one; removed per @dbarker's review that it's unnecessary API surface once the cost of passing it through is understood -- see below).

The 7 use cases

Context context = trace::SetSpan(original_context, my_span);
  1. EmitLogRecord("msg", context) -- explicit Context.
  2. EmitLogRecord("msg") -- implicit ambient.
  3. CreateLogRecord(); EmitLogRecord(r) -- no context anywhere.
  4. CreateLogRecord(context); EmitLogRecord(r) -- explicit to Create only.
  5. EmitLogRecord("msg", span_context) -- bare SpanContext.
  6. EmitLogRecord("msg", trace_id, span_id, flags)
  7. CreateLogRecord(span_context); EmitLogRecord(r)

Cases 1/2/5/6 already resolve context inside EmitLogRecord(args...) regardless of any processor (needed for the Enabled() filter chain) -- passing it through to processors costs nothing new.

Cases 3/4/7 use the two-step create-then-emit pattern, which the Logger has no way to carry an explicit context across: the resolved Context (and any live Span it owns) isn't retained anywhere between the two calls.

Two mechanisms for closing that gap were considered and rejected:

  • Storing the Context on the Recordable between the two calls. BatchLogRecordProcessor buffers a Recordable for up to scheduled_delay_millis (default 5000ms) before export, so a Context stashed there would keep its Span alive for that whole window -- an unrelated subsystem silently extending a span's lifetime.
  • Reconstructing a SpanContext from the record's own stamped fields. The base Recordable interface is write-only (no GetTraceId/GetSpanId), so recovering it generically would need either an unsafe downcast or expanding that interface for every processor's own Recordable implementation.

So: case 3 (no explicit context ever given) resolves ambient fresh at emit time -- spec-correct, since ambient-at-emit-time is the right answer when nothing explicit was supplied. Cases 4 and 7 (explicit context/span_context given to Create only) are a documented, tested limitation: the plain EmitLogRecord(record) call sees ambient, not the original context. Callers who need the processor to see the same context they gave CreateLogRecord must call EmitLogRecordWithContext(record, context) instead, re-passing the context they already have in hand. Case 7 isn't actually a regression versus case 5/6: a bare SpanContext never carried a live Span to begin with, so the ids delivered via EmitLogRecordWithContext are fully compliant for that case.

Changes

  • api/include/opentelemetry/logs/logger.h (ABI v2): added EmitLogRecordWithContext(record, resolved_context), a new virtual with a default that forwards to EmitLogRecord(record) so existing Logger implementations keep compiling unchanged. Distinct name (not an EmitLogRecord() overload) so a derived class overriding only the record-only overload can't hide it -- same reasoning as the LogRecordProcessor side below. EmitLogRecord(args...) now calls it with the context_or_span it already resolved, instead of discarding that resolution. Extracted the per-argument stamping loop (previously duplicated) into a shared StampLogRecordFields() helper.
  • sdk/include/opentelemetry/sdk/logs/processor.h: added OnEmitWithContext(record, context), unconditional (no opt-in flag), default forwards to OnEmit().
  • sdk/src/logs/logger.cc: EmitToProcessor() always calls OnEmitWithContext(). When no context was ever supplied to this specific call (the plain EmitLogRecord(record) path), it resolves ambient fresh as a best-effort fallback.
  • sdk/{include,src}/logs/multi_log_record_processor.{h,cc}: fans OnEmitWithContext() out to every child processor.

Verified unaffected: exporters/etw/include/opentelemetry/exporters/etw/etw_logger.h, the one real production logs::Logger subclass besides the SDK's own -- it only overrides the record-only EmitLogRecord(), not EmitLogRecordWithContext, so it keeps its existing ambient-fallback behavior unchanged. No production LogRecordProcessor subclass exists outside sdk/src/logs/ (checked exporters/otlp, elasticsearch, ostream, opentracing-shim).

Test plan

  • sdk/test/logs/logger_sdk_test.cc: ContextCapturingProcessor + one test per case above, including the two documented-limitation tests for cases 4/7 and their EmitLogRecordWithContext-fixed counterparts.
  • Three tests pin the exact RuntimeContext::GetCurrent() call count for cases 1/2/3 (0, 1, and 2 respectively), so the cost claim above is verifiable rather than asserted.
  • Full ctest green in a from-scratch ABI v2 build (-DWITH_ABI_VERSION_1=OFF -DWITH_ABI_VERSION_2=ON) -- 904/904.
  • Full ctest green in the existing ABI v1 build -- 1367/1367.
  • clang-format, doxygen docs/public/Doxyfile.lint, misspell all clean.

…nally

Split out of open-telemetry#4309 per review from dbarker and ThomsonTan: the
EventToSpanEventBridgeProcessor added there needs the resolved trace
Context at OnEmit time to reach the live Span it bridges an event onto.
Getting that right turned out to be public API surface for both
logs::Logger and sdk::logs::LogRecordProcessor, so it gets its own PR
with its own analysis before the bridge processor depends on it.

The bug: Logger::EmitLogRecord(record) re-derived the resolved context
from RuntimeContext::GetCurrent() (ambient) instead of reusing whatever
context had already been resolved for this record, so an explicitly
supplied context could be silently dropped in favor of whatever span
happened to be ambient at emit time.

Design, evaluated against the 7 use cases below: resolve context once
and always pass it to OnEmit, no per-processor opt-in.

* api/include/opentelemetry/logs/logger.h (ABI v2): added
  EmitLogRecordWithContext(record, resolved_context), a new virtual
  with a default that forwards to EmitLogRecord(record) so existing
  Logger implementations keep compiling unchanged. Distinct name
  (not an EmitLogRecord() overload) so a derived class overriding
  only the record-only overload can't hide it. EmitLogRecord(args...)
  now calls it with the context_or_span it already resolved for the
  Enabled() filter chain, instead of discarding that resolution.
  Extracted the per-argument stamping loop (previously duplicated)
  into a shared StampLogRecordFields() helper.

* sdk/include/opentelemetry/sdk/logs/processor.h: added
  OnEmitWithContext(record, context), unconditional (no opt-in flag),
  default forwards to OnEmit() so unrelated processors are unaffected.

* sdk/src/logs/logger.cc: EmitToProcessor() always calls
  OnEmitWithContext(). When no context was ever supplied to this
  specific call (the plain EmitLogRecord(record) path), it resolves
  ambient fresh as a best-effort fallback.

* sdk/{include,src}/logs/multi_log_record_processor.{h,cc}: fans
  OnEmitWithContext() out to every child processor.

The 7 use cases (matrix from review), and how each resolves:

  Context context = trace::SetSpan(original_context, my_span);

  1. EmitLogRecord("msg", context)          -- explicit Context.
  2. EmitLogRecord("msg")                   -- implicit ambient.
  3. CreateLogRecord(); EmitLogRecord(r)     -- no context anywhere.
  4. CreateLogRecord(context); EmitLogRecord(r) -- explicit to Create only.
  5. EmitLogRecord("msg", span_context)     -- bare SpanContext.
  6. EmitLogRecord("msg", trace_id, span_id, flags)
  7. CreateLogRecord(span_context); EmitLogRecord(r)

Cases 1/2/5/6 already resolve context inside EmitLogRecord(args...)
regardless of any processor -- passing it through costs nothing new.

Cases 3/4/7 use the two-step create-then-emit pattern, which the
Logger has no way to carry an explicit context across: the resolved
Context (and any live Span it owns) is not retained anywhere between
the two calls. Storing it on the Recordable to bridge that gap was
considered and rejected -- BatchLogRecordProcessor buffers a
Recordable for up to scheduled_delay_millis (default 5000ms) before
export, so a Context stashed there would keep its Span alive for
that whole window, an unrelated subsystem silently extending a
span's lifetime. Reconstructing a SpanContext from the record's own
stamped fields was also considered and rejected -- the base
Recordable interface is write-only (no GetTraceId/GetSpanId), so
recovering it generically would need either an unsafe downcast or
expanding that interface for every processor's own Recordable
implementation.

So case 3 (no explicit context ever given) resolves ambient fresh at
emit time -- spec-correct, since ambient-at-emit-time is the right
answer when nothing explicit was supplied. Cases 4 and 7 (explicit
context/span_context given to Create only) are a documented,
tested limitation: the plain EmitLogRecord(record) call sees
ambient, not the original context. Callers who need the processor to
see the same context they gave CreateLogRecord must call
EmitLogRecordWithContext(record, context) instead, re-passing the
context they already have in hand. Case 7 is not actually a
regression versus case 5/6: a bare SpanContext never carried a live
Span to begin with, so the ids delivered via
EmitLogRecordWithContext are full compliance for that case.

Verified etw::Logger (exporters/etw/include/opentelemetry/exporters/etw/etw_logger.h),
the one real production logs::Logger subclass besides the SDK's own:
it only overrides the record-only EmitLogRecord(), not
EmitLogRecordWithContext, so it is unaffected and keeps its existing
ambient-fallback behavior. No production LogRecordProcessor subclass
exists outside sdk/src/logs/ (checked exporters/otlp, elasticsearch,
ostream, opentracing-shim).

Tests: sdk/test/logs/logger_sdk_test.cc adds ContextCapturingProcessor
and one test per case above, including the two documented-limitation
tests for cases 4/7 and their EmitLogRecordWithContext-fixed
counterparts, plus three tests pinning the exact GetCurrent() call
count for cases 1/2/3 (0, 1, and 2 respectively) to make the cost
claim in this description verifiable rather than asserted.

Verified in a from-scratch ABI v2 build (WITH_ABI_VERSION_1=OFF
WITH_ABI_VERSION_2=ON) as well as the existing ABI v1 build: full
ctest suites green in both (904/904 and 1367/1367), clang-format
applied, doxygen and misspell clean.
@om7057
om7057 requested a review from a team as a code owner August 12, 2026 19:40
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.56098% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 83.21%. Comparing base (8074e35) to head (faca99f).

Files with missing lines Patch % Lines
sdk/src/logs/logger.cc 94.45% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4421      +/-   ##
==========================================
- Coverage   83.22%   83.21%   -0.01%     
==========================================
  Files         521      521              
  Lines       20404    20443      +39     
==========================================
+ Hits        16980    17010      +30     
- Misses       3424     3433       +9     
Files with missing lines Coverage Δ
api/include/opentelemetry/logs/logger.h 82.48% <100.00%> (+1.37%) ⬆️
sdk/include/opentelemetry/sdk/logs/processor.h 100.00% <100.00%> (ø)
sdk/src/logs/multi_log_record_processor.cc 86.46% <100.00%> (-10.01%) ⬇️
sdk/src/logs/logger.cc 94.12% <94.45%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

om7057 added 4 commits August 13, 2026 07:53
- sdk/test/logs/logger_sdk_test.cc: mark nostd/unique_ptr.h as IWYU
  pragma: keep, since it's only referenced from ABI v2 gated code, same
  pattern as the other ABI-gated includes in this file.
- Add tests for lines codecov flagged as uncovered in the diff: the
  disabled-logger path through EmitLogRecordWithContext, the null
  log_record guard in EmitLogRecord(args...) after
  CreateLogRecord(context_or_span) returns null, and
  MultiLogRecordProcessor::OnEmitWithContext's fan-out and null-record
  guard.
The new MultiLogRecordProcessor::OnEmitWithContext tests construct a
SpanContext directly, which needs trace/span_id.h, trace/trace_flags.h,
and trace/trace_id.h -- flagged identically across all three iwyu
configs (abiv1, abiv1-preview, abiv2-preview).
@om7057

om7057 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@dbarker small reminder on this one!

@om7057

om7057 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@dbarker I was unnecessarily rushing through this PR; I should have taken my time to split it out from this: #4309
Apologies for that; I'll wait and verify all the changes myself one by one, instead of just trying to get the changes merged.

…solved-context

# Conflicts:
#	CHANGELOG.md
#	sdk/test/logs/simple_log_record_processor_test.cc
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.

1 participant