Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,12 @@ 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,
settledResourceAttributes,
} from './inspect';

export enum ForceFlushState {
'resolved',
Expand Down Expand Up @@ -125,4 +131,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: settledResourceAttributes(this._resource) },
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
);
}
}
33 changes: 33 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,12 @@ 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,
settledResourceAttributes,
} from './inspect';

/**
* This type provides the properties of @link{ReadableSpan} at the same time
Expand Down Expand Up @@ -524,4 +530,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: settledResourceAttributes(this.resource) },
};
return formatInspect('SpanImpl', payload, depth, options, inspect);
}
}
20 changes: 20 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,12 @@ 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,
settledResourceAttributes,
} from './inspect';

/**
* This class represents a basic tracer.
Expand Down Expand Up @@ -255,4 +261,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: settledResourceAttributes(this._resource) },
spanLimits: this._spanLimits,
generalLimits: this._generalLimits,
};
return formatInspect('Tracer', payload, depth, options, inspect);
}
}
71 changes: 71 additions & 0 deletions packages/opentelemetry-sdk-trace-base/src/inspect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

import type { Attributes, AttributeValue } from '@opentelemetry/api';
import type { Resource } from '@opentelemetry/resources';

/**
* 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');

/**
* Collect a Resource's settled attributes without touching the
* `attributes` getter, which emits diag.error/debug entries when async
* attribute detectors are still pending. Promise-like (unsettled)
* entries are silently skipped so logging a Span/Tracer/Provider during
* startup doesn't recurse through the diag pipeline.
*/
export function settledResourceAttributes(resource: Resource): Attributes {
const attrs: Attributes = {};
for (const [k, v] of resource.getRawAttributes()) {
if (typeof (v as Partial<PromiseLike<unknown>>)?.then === 'function') {
continue;
}
if (v != null) {
attrs[k] ??= v as AttributeValue;
}
}
return attrs;
}

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)}`;
}
153 changes: 153 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,153 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

import type { SpanContext } from '@opentelemetry/api';
import {
diag,
DiagLogLevel,
ROOT_CONTEXT,
SpanKind,
TraceFlags,
} from '@opentelemetry/api';
import { resourceFromAttributes } from '@opentelemetry/resources';
import * as assert from 'assert';
import * as sinon from 'sinon';
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:'"));
});
});

describe('resource with unsettled async attributes', () => {
let diagError: sinon.SinonSpy;
let diagDebug: sinon.SinonSpy;

beforeEach(() => {
diagError = sinon.spy();
diagDebug = sinon.spy();
diag.setLogger(
{
error: diagError,
warn: () => {},
info: () => {},
debug: diagDebug,
verbose: () => {},
},
DiagLogLevel.DEBUG
);
});
afterEach(() => diag.disable());

it('should not emit diag warnings and should skip unsettled attributes', () => {
const resource = resourceFromAttributes({
'service.name': 'svc',
'cloud.region': Promise.resolve('us-east-1'),
});
const provider = new BasicTracerProvider({ resource });
const t = provider.getTracer('default') as Tracer;
const span = new SpanImpl({
scope: t.instrumentationScope,
resource: t['_resource'],
context: ROOT_CONTEXT,
spanContext,
name: 'span1',
kind: SpanKind.INTERNAL,
spanLimits: t.getSpanLimits(),
spanProcessor: t['_spanProcessor'],
});

const out = util.inspect(span, { depth: 5, colors: false });

assert.strictEqual(
diagError.callCount,
0,
`unexpected diag.error calls: ${diagError.args.map(a => a.join(' ')).join('; ')}`
);
assert.ok(
!diagDebug.args.some(
([msg]) =>
typeof msg === 'string' &&
msg.startsWith('Unsettled resource attribute')
),
'inspect should not emit "Unsettled resource attribute" debug logs'
);
assert.ok(out.includes("'service.name': 'svc'"));
assert.ok(!out.includes('cloud.region'));
});
});
});
Loading