Skip to content

feat(sdk-trace-base): add util.inspect support for SpanImpl, Tracer, BasicTracerProvider#6690

Merged
trentm merged 5 commits into
open-telemetry:mainfrom
mcollina:feat/sdk-trace-base-inspect-symbol
May 15, 2026
Merged

feat(sdk-trace-base): add util.inspect support for SpanImpl, Tracer, BasicTracerProvider#6690
trentm merged 5 commits into
open-telemetry:mainfrom
mcollina:feat/sdk-trace-base-inspect-symbol

Conversation

@mcollina

@mcollina mcollina commented May 8, 2026

Copy link
Copy Markdown
Contributor

Which problem is this PR solving?

Debugging the trace SDK currently means logging spans that render as SpanImpl {} (empty-looking) or as a wall of private fields. There is no way to ask diag.debug to pretty-print a SpanImpl, Tracer, or BasicTracerProvider — the only existing pretty-printer is buried inside ConsoleSpanExporter._exportInfo and is exporter-only.

Short description of the changes

Implements Symbol.for('nodejs.util.inspect.custom') on SpanImpl, Tracer, and BasicTracerProvider in @opentelemetry/sdk-trace-base. Once the symbol is defined, the objects render cleanly via console.log, util.inspect, or any logger that honors the symbol — most importantly DiagConsoleLogger, so users can write diag.debug('span', span) and get useful output.

  • SpanImpl's payload mirrors ConsoleSpanExporter._exportInfo plus internal state (ended, drop counters, scope, resource).
  • Tracer exposes instrumentationScope, resource, and the active span/general limits.
  • BasicTracerProvider exposes resource, registered tracer keys, and span-processor class names.
  • A shared formatInspect helper centralizes the class-tag prefix, depth handling, and the [ClassName] stub when the depth budget is exhausted; it falls back to returning the raw payload when the runtime does not pass an inspect callback (e.g. browsers), so the change stays cross-platform.

Example output:

SpanImpl {
  name: 'smoke-span',
  kind: 0,
  spanContext: { traceId: '69d4...', spanId: 'aa04...', traceFlags: 1 },
  status: { code: 0 },
  attributes: { 'http.method': 'GET' },
  ...
}

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

  • Added 4 new unit tests in Span.test.ts and Tracer.test.ts covering the class-tagged output and the depth-budget stub.
  • Verified the full sdk-trace-base test suite passes (247 tests).
  • Smoke-tested end-to-end: registered DiagConsoleLogger at DEBUG level and confirmed diag.debug(...) renders SpanImpl, Tracer, and BasicTracerProvider with all key fields.

Checklist

  • Followed the style guidelines of this project
  • Unit tests have been added
  • Documentation has been updated — public API surface unchanged; the symbol is invoked implicitly by Node's inspect machinery.

@mcollina
mcollina requested a review from a team as a code owner May 8, 2026 10:19
@linux-foundation-easycla

linux-foundation-easycla Bot commented May 8, 2026

Copy link
Copy Markdown

CLA Signed

The committers listed above are authorized under a signed CLA.

@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.93939% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.86%. Comparing base (95e48e7) to head (2680da7).
⚠️ Report is 16 commits behind head on main.

Files with missing lines Patch % Lines
...elemetry-sdk-trace-base/src/BasicTracerProvider.ts 83.33% 1 Missing ⚠️
...ckages/opentelemetry-sdk-trace-base/src/inspect.ts 94.73% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6690      +/-   ##
==========================================
+ Coverage   94.79%   94.86%   +0.06%     
==========================================
  Files         374      377       +3     
  Lines       12445    12749     +304     
  Branches     2842     2887      +45     
==========================================
+ Hits        11797    12094     +297     
- Misses        648      655       +7     
Files with missing lines Coverage Δ
packages/opentelemetry-sdk-trace-base/src/Span.ts 98.34% <100.00%> (+0.02%) ⬆️
...ackages/opentelemetry-sdk-trace-base/src/Tracer.ts 98.78% <100.00%> (+0.06%) ⬆️
...elemetry-sdk-trace-base/src/BasicTracerProvider.ts 93.54% <83.33%> (-1.10%) ⬇️
...ckages/opentelemetry-sdk-trace-base/src/inspect.ts 94.73% <94.73%> (ø)

... and 12 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mcollina added 2 commits May 10, 2026 13:35
…BasicTracerProvider

Implement Symbol.for('nodejs.util.inspect.custom') on the core trace SDK
classes so they render with class tags and key fields when logged via
console.log, util.inspect, or any logger that honors the symbol — including
DiagConsoleLogger used by diag.debug. SpanImpl's payload mirrors
ConsoleSpanExporter's _exportInfo plus internal state (ended flag, drop
counts, scope, resource); Tracer exposes scope, resource, and limits;
BasicTracerProvider exposes resource, registered tracer keys, and span
processor class names. A shared formatInspect helper centralizes depth
handling and the [ClassName] stub when the depth budget is exhausted, and
falls back to a plain payload object in environments that don't pass an
inspect callback (e.g. browsers).
@mcollina
mcollina force-pushed the feat/sdk-trace-base-inspect-symbol branch from 024f911 to 72ffcf0 Compare May 10, 2026 11:50
The karma browser harness loads tests from test/common, and the browser
shim of `util.inspect` does not honor `Symbol.for('nodejs.util.inspect.custom')`
the way Node does — assertions on the class-tagged prefix were failing.
Move the inspect tests under test/node so only the Node test runner picks
them up; the inspect symbol itself is a no-op in browsers, which is the
intended behaviour.

@trentm trentm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this will be super-useful for internal dev/debug.

I imagine we'll want to do the same for SDK implementation classes in sdk-metrics and sdk-logs eventually as well. We can feel out then if we want to share the inspect.ts functionality (perhaps in @opentelemetry/core).

Comment thread packages/opentelemetry-sdk-trace-base/src/BasicTracerProvider.ts
droppedEventsCount: this._droppedEventsCount,
droppedLinksCount: this._droppedLinksCount,
instrumentationScope: this.instrumentationScope,
resource: { attributes: this.resource.attributes },

@trentm trentm May 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@pichlermarc noted (during the OTel JS SIG call earlier today) that accessing resource.attributes can emit other diag logs (at both error- and debug-level) if there are unsettled async attributes. E.g.:

const {NodeSDK} = require('@opentelemetry/sdk-node');
const {awsEc2Detector} = require('/Users/trentm/src/opentelemetry-js-contrib/packages/resource-detector-aws');

require('node:util').inspect.defaultOptions.depth = 10;
process.env.OTEL_LOG_LEVEL = 'debug';
const sdk = new NodeSDK({
  resourceDetectors: [awsEc2Detector]
});
sdk.start();
process.once('beforeExit', async () => {
  await sdk.shutdown();
})

const { diag, trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('default')
tracer.startActiveSpan('a-span', {attributes: {anAttr:'aValue'}}, span => {
  span.end();
  diag.info('this is my span:', span);
});

Running that:

...
Accessing resource attributes before async attributes settled    <--- This one at "error" level
Unsettled resource attribute cloud.provider skipped
Unsettled resource attribute cloud.platform skipped
Unsettled resource attribute cloud.account.id skipped
Unsettled resource attribute cloud.region skipped
Unsettled resource attribute cloud.availability_zone skipped
Unsettled resource attribute host.id skipped
Unsettled resource attribute host.type skipped
Unsettled resource attribute host.name skipped
diag.info span: SpanImpl {
  name: 'a-span',
  kind: 0,
  spanContext: {
    traceId: '3ff9931075a6792d24eedadde6a19b6f',
    spanId: '6b114a6edc91125d',
    traceFlags: 1,
    traceState: undefined
  },
  parentSpanContext: undefined,
  status: { code: 0 },
  startTime: [ 1778714142, 238000000 ],
  endTime: [ 1778714142, 238157625 ],
  duration: [ 0, 157625 ],
  ended: true,
  attributes: { anAttr: 'aValue' },
  events: [],
  links: [],
  droppedAttributesCount: 0,
  droppedEventsCount: 0,
  droppedLinksCount: 0,
  instrumentationScope: { name: 'default', version: undefined, schemaUrl: undefined },
  resource: {
    attributes: {
      'service.name': 'unknown_service:node',
      'telemetry.sdk.language': 'nodejs',
      'telemetry.sdk.name': 'opentelemetry',
      'telemetry.sdk.version': '2.7.1'
    }
  }
}
...

This is unfortunate. Possible improvements:

  1. Use this.resource.getRawAttributes() and only include the settled ones, e.g.:
diff --git a/packages/opentelemetry-sdk-trace-base/src/Span.ts b/packages/opentelemetry-sdk-trace-base/src/Span.ts
index b3b9e3b8f..ed76ce745 100644
--- a/packages/opentelemetry-sdk-trace-base/src/Span.ts
+++ b/packages/opentelemetry-sdk-trace-base/src/Span.ts
@@ -532,6 +532,18 @@ export class SpanImpl implements Span {
     options: InspectStylizeOptions | undefined,
     inspect: InspectFn | undefined
   ): unknown {
+    // This reproduces ResourceImpl#attributes without any diag logging for
+    // unsettled attributes.
+    const settledAttrs: Attributes = {};
+    for (const [k, v] of this.resource.getRawAttributes()) {
+      if (typeof (v as Partial<PromiseLike<unknown>>)?.then === 'function') {
+        continue;
+      }
+      if (v != null) {
+        settledAttrs[k] ??= v as AttributeValue;
+      }
+    }
+
     const payload = {
       name: this.name,
       kind: this.kind,
@@ -549,7 +561,7 @@ export class SpanImpl implements Span {
       droppedEventsCount: this._droppedEventsCount,
       droppedLinksCount: this._droppedLinksCount,
       instrumentationScope: this.instrumentationScope,
-      resource: { attributes: this.resource.attributes },
+      resource: { attributes: settledAttrs },
     };
     return formatInspect('SpanImpl', payload, depth, options, inspect);
   }
  1. Put a custom inspect method on ResourceImpl in the resources package and have it deal with it.

Though it could be misleading for those debugging early application behaviour (before all resource attributes are settled), I think it is fine to have the pretty-print representation just skip unsettled attributes -- for the common case.

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.

Good catch — addressed in 34539e6. I added a settledResourceAttributes() helper in inspect.ts that walks getRawAttributes() and silently skips promise-shaped entries, and applied it from all three classes (Span, Tracer, BasicTracerProvider) so none of them touch the attributes getter from inside an inspect call. There's also a regression test asserting no diag.error/diag.debug fires when inspecting a Span whose Resource has unsettled async attributes.

@trentm

trentm commented May 13, 2026

Copy link
Copy Markdown
Contributor

With a default depth of a measly 2, and somewhat obtuse inspect.defaultOptions.depth = 10; to increase Node.js's default inspect depth, I feel like I'd want the SDK to offer a deeper depth (perhaps by default) via something like:

type SdkDiagConsoleLoggerOptions = {
  depth?: number;
};
class SdkDiagConsoleLogger implements DiagLogger {
  private _logger: Console;
  constructor(opts?: SdkDiagConsoleLoggerOptions) {
    this._logger = new Console({
      stdout: process.stdout,
      stderr: process.stderr,
      inspectOptions: { depth: opts?.depth ?? 2 },
    });
  }
  // @ts-expect-error any
  public error(...args) { return this._logger.error(...args); }
  // @ts-expect-error any
  public warn(...args) { return this._logger.warn(...args); }
  // @ts-expect-error any
  public info(...args) { return this._logger.info(...args); }
  // @ts-expect-error any
  public debug(...args) { return this._logger.debug(...args); }
  // @ts-expect-error any
  public verbose(...args) { return this._logger.trace(...args); }
}

Update: To be clear, I'm just thinking out loud. Adding a SdkDiagConsoleLogger is obviously not something this PR needs to deal with.

mcollina and others added 2 commits May 15, 2026 18:14
…ending async attributes

The custom util.inspect impls on SpanImpl, Tracer, and BasicTracerProvider
read this.resource.attributes, whose getter emits diag.error
("Accessing resource attributes before async attributes settled") plus a
diag.debug per pending key when async detectors haven't resolved. Logging
a span via diag.debug during startup would therefore trigger a cascade
of diag entries from inside the inspect call.

Replace the getter with a new settledResourceAttributes() helper that
walks Resource.getRawAttributes() directly and silently skips
promise-shaped (unsettled) entries — matching the approach @trentm
proposed in code review.
@trentm
trentm added this pull request to the merge queue May 15, 2026
Merged via the queue into open-telemetry:main with commit 926ee3d May 15, 2026
29 checks passed
@otelbot

otelbot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Thank you for your contribution @mcollina! 🎉 We would like to hear from you about your experience contributing to OpenTelemetry by taking a few minutes to fill out this survey.

@mcollina
mcollina deleted the feat/sdk-trace-base-inspect-symbol branch June 15, 2026 14:07
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