Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2

### :rocket: Features

* feat(sdk-trace-base): pretty-print `SpanImpl`, `Tracer`, and `BasicTracerProvider` via `util.inspect` so they render through `diag` and `console.log` [#6690](https://github.com/open-telemetry/opentelemetry-js/pull/6690) @mcollina

### :bug: Bug Fixes

### :books: Documentation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { loadDefaultConfig } from './config';
import { MultiSpanProcessor } from './MultiSpanProcessor';
import type { TracerConfig } from './types';
import { reconfigureLimits } from './utility';
import type { InspectFn, InspectStylizeOptions } from './inspect';
import { formatInspect, inspectCustom } from './inspect';

export enum ForceFlushState {
'resolved',
Expand Down Expand Up @@ -125,4 +127,28 @@ export class BasicTracerProvider implements TracerProvider {
shutdown(): Promise<void> {
return this._activeSpanProcessor.shutdown();
}

[inspectCustom](
depth: number,
options: InspectStylizeOptions | undefined,
inspect: InspectFn | undefined
): unknown {
const processors = this._activeSpanProcessor[
'_spanProcessors'
] as SpanProcessor[];
const payload = {
resource: { attributes: this._resource.attributes },
tracers: Array.from(this._tracers.keys()),
Comment thread
mcollina marked this conversation as resolved.
spanProcessors: processors.map(
p => p.constructor?.name ?? 'SpanProcessor'
),
};
return formatInspect(
'BasicTracerProvider',
payload,
depth,
options,
inspect
);
}
}
29 changes: 29 additions & 0 deletions packages/opentelemetry-sdk-trace-base/src/Span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { ExceptionEventName } from './enums';
import type { SpanProcessor } from './SpanProcessor';
import type { TimedEvent } from './TimedEvent';
import type { SpanLimits } from './types';
import type { InspectFn, InspectStylizeOptions } from './inspect';
import { formatInspect, inspectCustom } from './inspect';

/**
* This type provides the properties of @link{ReadableSpan} at the same time
Expand Down Expand Up @@ -524,4 +526,31 @@ export class SpanImpl implements Span {
// Other types, no need to apply value length limit
return value;
}

[inspectCustom](
depth: number,
options: InspectStylizeOptions | undefined,
inspect: InspectFn | undefined
): unknown {
const payload = {
name: this.name,
kind: this.kind,
spanContext: this._spanContext,
parentSpanContext: this.parentSpanContext,
status: this.status,
startTime: this.startTime,
endTime: this.endTime,
duration: this._duration,
ended: this._ended,
attributes: this.attributes,
events: this.events,
links: this.links,
droppedAttributesCount: this._droppedAttributesCount,
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.

};
return formatInspect('SpanImpl', payload, depth, options, inspect);
}
}
16 changes: 16 additions & 0 deletions packages/opentelemetry-sdk-trace-base/src/Tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { RandomIdGenerator } from './platform';
import type { Resource } from '@opentelemetry/resources';
import { TracerMetrics } from './TracerMetrics';
import { VERSION } from './version';
import type { InspectFn, InspectStylizeOptions } from './inspect';
import { formatInspect, inspectCustom } from './inspect';

/**
* This class represents a basic tracer.
Expand Down Expand Up @@ -255,4 +257,18 @@ export class Tracer implements api.Tracer {
getSpanLimits(): SpanLimits {
return this._spanLimits;
}

[inspectCustom](
depth: number,
options: InspectStylizeOptions | undefined,
inspect: InspectFn | undefined
): unknown {
const payload = {
instrumentationScope: this.instrumentationScope,
resource: { attributes: this._resource.attributes },
spanLimits: this._spanLimits,
generalLimits: this._generalLimits,
};
return formatInspect('Tracer', payload, depth, options, inspect);
}
}
48 changes: 48 additions & 0 deletions packages/opentelemetry-sdk-trace-base/src/inspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Well-known symbol used by Node.js `util.inspect` (and `console.*`) to
* render an object via a custom representation. Defined as a global Symbol
* so it works without importing from `node:util`, keeping this module safe
* for browser builds (where the symbol is simply never looked up).
*/
export const inspectCustom = Symbol.for('nodejs.util.inspect.custom');

export type InspectFn = (value: unknown, options: unknown) => string;

export interface InspectStylizeOptions {
depth?: number | null;
stylize?: (text: string, styleType: string) => string;
}

/**
* Build a class-tagged inspect representation. Returns a stub like
* `[ClassName]` once the recursion budget is exhausted, otherwise returns
* `ClassName <inspected payload>` so nested fields keep proper coloring,
* indentation, and depth handling. In environments that don't supply an
* `inspect` callback (e.g. browsers), falls back to returning the raw
* payload object.
*/
export function formatInspect(
className: string,
payload: object,
depth: number,
options: InspectStylizeOptions | undefined,
inspect: InspectFn | undefined
): unknown {
if (typeof depth === 'number' && depth < 0) {
const tag = `[${className}]`;
return options?.stylize ? options.stylize(tag, 'special') : tag;
}
if (typeof inspect !== 'function' || !options) {
return payload;
}
const childOptions = {
...options,
depth: options.depth == null ? options.depth : options.depth - 1,
};
return `${className} ${inspect(payload, childOptions)}`;
}
87 changes: 87 additions & 0 deletions packages/opentelemetry-sdk-trace-base/test/node/inspect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

import type { SpanContext } from '@opentelemetry/api';
import { ROOT_CONTEXT, SpanKind, TraceFlags } from '@opentelemetry/api';
import * as assert from 'assert';
import * as util from 'util';
import { BasicTracerProvider } from '../../src';
import { SpanImpl } from '../../src/Span';
import type { Tracer } from '../../src/Tracer';

describe('util.inspect', () => {
const tracerProvider = new BasicTracerProvider();
const tracer = tracerProvider.getTracer('default') as Tracer;
const spanContext: SpanContext = {
traceId: 'd4cda95b652f4a1592b449d5929fda1b',
spanId: '6e0c63257de34c92',
traceFlags: TraceFlags.SAMPLED,
};

describe('SpanImpl', () => {
it('should render with class tag and key fields', () => {
const span = new SpanImpl({
scope: tracer.instrumentationScope,
resource: tracer['_resource'],
context: ROOT_CONTEXT,
spanContext,
name: 'span1',
kind: SpanKind.CLIENT,
spanLimits: tracer.getSpanLimits(),
spanProcessor: tracer['_spanProcessor'],
attributes: { foo: 'bar' },
});

const out = util.inspect(span, { depth: 5, colors: false });
assert.ok(out.startsWith('SpanImpl '), `unexpected prefix: ${out}`);
assert.ok(out.includes("name: 'span1'"));
assert.ok(out.includes(spanContext.traceId));
assert.ok(out.includes(spanContext.spanId));
assert.ok(out.includes("foo: 'bar'"));
});

it('should collapse to a stub when depth budget is exhausted', () => {
const span = new SpanImpl({
scope: tracer.instrumentationScope,
resource: tracer['_resource'],
context: ROOT_CONTEXT,
spanContext,
name: 'span1',
kind: SpanKind.CLIENT,
spanLimits: tracer.getSpanLimits(),
spanProcessor: tracer['_spanProcessor'],
});

const out = util.inspect({ span }, { depth: 0, colors: false });
assert.ok(out.includes('[SpanImpl]'), `unexpected output: ${out}`);
});
});

describe('Tracer', () => {
it('should render with scope and resource', () => {
const t = tracerProvider.getTracer('debug-scope', '1.2.3');
const out = util.inspect(t, { depth: 4, colors: false });
assert.ok(out.startsWith('Tracer '), `unexpected prefix: ${out}`);
assert.ok(out.includes("name: 'debug-scope'"));
assert.ok(out.includes("version: '1.2.3'"));
assert.ok(out.includes('spanLimits'));
});
});

describe('BasicTracerProvider', () => {
it('should render with tracer keys', () => {
const provider = new BasicTracerProvider();
provider.getTracer('a');
provider.getTracer('b', '0.0.1');
const out = util.inspect(provider, { depth: 4, colors: false });
assert.ok(
out.startsWith('BasicTracerProvider '),
`unexpected prefix: ${out}`
);
assert.ok(out.includes("'a@:'"));
assert.ok(out.includes("'b@0.0.1:'"));
});
});
});
Loading