feat(opentelemetry-resources): add schema url (#5753)

Co-authored-by: Marc Pichler <marc.pichler@dynatrace.com>
This commit is contained in:
Christopher Ehrlich 2025-08-20 19:01:16 +02:00 committed by GitHub
parent 60b701dc9d
commit 23677fd0da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 429 additions and 76 deletions

View File

@ -16,6 +16,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2
### :rocket: Features
* feat(opentelemetry-resources): add schema url [#5070](https://github.com/open-telemetry/opentelemetry-js/pull/5753) @c-ehrlich
### :bug: Bug Fixes
* fix(sdk-metrics): Remove invalid default value for `startTime` param to ExponentialHistogramAccumulation. This only impacted the closurescript compiler. [#5763](https://github.com/open-telemetry/opentelemetry-js/pull/5763) @trentm

View File

@ -21,6 +21,9 @@ export interface Resource {
/** Resource droppedAttributesCount */
droppedAttributesCount: number;
/** Resource schemaUrl */
schemaUrl?: string;
}
/** Properties of an InstrumentationScope. */

View File

@ -24,10 +24,15 @@ import { InstrumentationScope } from '@opentelemetry/core';
import { Resource as ISdkResource } from '@opentelemetry/resources';
export function createResource(resource: ISdkResource): Resource {
return {
const result: Resource = {
attributes: toAttributes(resource.attributes),
droppedAttributesCount: 0,
};
const schemaUrl = resource.schemaUrl;
if (schemaUrl && schemaUrl !== '') result.schemaUrl = schemaUrl;
return result;
}
export function createInstrumentationScope(

View File

@ -79,17 +79,20 @@ function logRecordsToResourceLogs(
encoder: Encoder
): IResourceLogs[] {
const resourceMap = createResourceMap(logRecords);
return Array.from(resourceMap, ([resource, ismMap]) => ({
resource: createResource(resource),
scopeLogs: Array.from(ismMap, ([, scopeLogs]) => {
return {
scope: createInstrumentationScope(scopeLogs[0].instrumentationScope),
logRecords: scopeLogs.map(log => toLogRecord(log, encoder)),
schemaUrl: scopeLogs[0].instrumentationScope.schemaUrl,
};
}),
schemaUrl: undefined,
}));
return Array.from(resourceMap, ([resource, ismMap]) => {
const processedResource = createResource(resource);
return {
resource: processedResource,
scopeLogs: Array.from(ismMap, ([, scopeLogs]) => {
return {
scope: createInstrumentationScope(scopeLogs[0].instrumentationScope),
logRecords: scopeLogs.map(log => toLogRecord(log, encoder)),
schemaUrl: scopeLogs[0].instrumentationScope.schemaUrl,
};
}),
schemaUrl: processedResource.schemaUrl,
};
});
}
function toLogRecord(log: ReadableLogRecord, encoder: Encoder): ILogRecord {

View File

@ -47,9 +47,10 @@ export function toResourceMetrics(
options?: OtlpEncodingOptions
): IResourceMetrics {
const encoder = getOtlpEncoder(options);
const processedResource = createResource(resourceMetrics.resource);
return {
resource: createResource(resourceMetrics.resource),
schemaUrl: undefined,
resource: processedResource,
schemaUrl: processedResource.schemaUrl,
scopeMetrics: toScopeMetrics(resourceMetrics.scopeMetrics, encoder),
};
}

View File

@ -170,11 +170,11 @@ function spanRecordsToResourceSpans(
}
ilmEntry = ilmIterator.next();
}
// TODO SDK types don't provide resource schema URL at this time
const processedResource = createResource(resource);
const transformedSpans: IResourceSpans = {
resource: createResource(resource),
resource: processedResource,
scopeSpans: scopeResourceSpans,
schemaUrl: undefined,
schemaUrl: processedResource.schemaUrl,
};
out.push(transformedSpans);

View File

@ -144,6 +144,27 @@ function createExpectedLogProtobuf(): IExportLogsServiceRequest {
};
}
const DEFAULT_LOG_FRAGMENT: Omit<
ReadableLogRecord,
'resource' | 'instrumentationScope'
> = {
hrTime: [1680253513, 123241635] as HrTime,
hrTimeObserved: [1683526948, 965142784] as HrTime,
attributes: {
'some-attribute': 'some attribute value',
},
droppedAttributesCount: 0,
severityNumber: SeverityNumber.ERROR,
severityText: 'error',
body: 'some_log_body',
eventName: 'some.event.name',
spanContext: {
spanId: '0000000000000002',
traceFlags: TraceFlags.SAMPLED,
traceId: '00000000000000000000000000000001',
},
} as const;
describe('Logs', () => {
let resource_1: Resource;
let resource_2: Resource;
@ -166,6 +187,18 @@ describe('Logs', () => {
// using `resource_2`, `scope_1`, `log_fragment_1`
let log_2_1_1: ReadableLogRecord;
function createReadableLogRecord(
resource: Resource,
scope: InstrumentationScope,
logFragment: Omit<ReadableLogRecord, 'resource' | 'instrumentationScope'>
): ReadableLogRecord {
return {
...logFragment,
resource: resource,
instrumentationScope: scope,
} as ReadableLogRecord;
}
beforeEach(() => {
resource_1 = resourceFromAttributes({
'resource-attribute': 'some attribute value',
@ -181,23 +214,8 @@ describe('Logs', () => {
scope_2 = {
name: 'scope_name_2',
};
const log_fragment_1 = {
hrTime: [1680253513, 123241635] as HrTime,
hrTimeObserved: [1683526948, 965142784] as HrTime,
attributes: {
'some-attribute': 'some attribute value',
},
droppedAttributesCount: 0,
severityNumber: SeverityNumber.ERROR,
severityText: 'error',
body: 'some_log_body',
eventName: 'some.event.name',
spanContext: {
spanId: '0000000000000002',
traceFlags: TraceFlags.SAMPLED,
traceId: '00000000000000000000000000000001',
},
};
const log_fragment_1 = DEFAULT_LOG_FRAGMENT;
const log_fragment_2 = {
hrTime: [1680253797, 687038506] as HrTime,
hrTimeObserved: [1680253797, 687038506] as HrTime,
@ -206,26 +224,11 @@ describe('Logs', () => {
},
droppedAttributesCount: 0,
};
log_1_1_1 = {
...log_fragment_1,
resource: resource_1,
instrumentationScope: scope_1,
};
log_1_1_2 = {
...log_fragment_2,
resource: resource_1,
instrumentationScope: scope_1,
};
log_1_2_1 = {
...log_fragment_1,
resource: resource_1,
instrumentationScope: scope_2,
};
log_2_1_1 = {
...log_fragment_1,
resource: resource_2,
instrumentationScope: scope_1,
};
log_1_1_1 = createReadableLogRecord(resource_1, scope_1, log_fragment_1);
log_1_1_2 = createReadableLogRecord(resource_1, scope_1, log_fragment_2);
log_1_2_1 = createReadableLogRecord(resource_1, scope_2, log_fragment_1);
log_2_1_1 = createReadableLogRecord(resource_2, scope_1, log_fragment_1);
});
describe('createExportLogsServiceRequest', () => {
@ -292,6 +295,30 @@ describe('Logs', () => {
assert.ok(exportRequest);
assert.strictEqual(exportRequest.resourceLogs?.length, 2);
});
it('supports schema URL on resource', () => {
const resourceWithSchema = resourceFromAttributes(
{},
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
const logWithSchema = createReadableLogRecord(
resourceWithSchema,
scope_1,
DEFAULT_LOG_FRAGMENT
);
const exportRequest = createExportLogsServiceRequest([logWithSchema], {
useHex: true,
});
assert.ok(exportRequest);
assert.strictEqual(exportRequest.resourceLogs?.length, 1);
assert.strictEqual(
exportRequest.resourceLogs?.[0].schemaUrl,
'https://opentelemetry.test/schemas/1.2.3'
);
});
});
describe('ProtobufLogsSerializer', function () {

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ValueType } from '@opentelemetry/api';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { Resource, resourceFromAttributes } from '@opentelemetry/resources';
import {
AggregationTemporality,
DataPointType,
@ -301,10 +301,16 @@ describe('Metrics', () => {
};
}
function createResourceMetrics(metricData: MetricData[]): ResourceMetrics {
const resource = resourceFromAttributes({
'resource-attribute': 'resource attribute value',
});
function createResourceMetrics(
metricData: MetricData[],
customResource?: Resource
): ResourceMetrics {
const resource =
customResource ||
resourceFromAttributes({
'resource-attribute': 'resource attribute value',
});
return {
resource: resource,
scopeMetrics: [
@ -773,6 +779,29 @@ describe('Metrics', () => {
});
});
});
it('supports schema URL on resource', () => {
const resourceWithSchema = resourceFromAttributes(
{},
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
const resourceMetrics = createResourceMetrics(
[createCounterData(10, AggregationTemporality.DELTA)],
resourceWithSchema
);
const exportRequest = createExportMetricsServiceRequest([
resourceMetrics,
]);
assert.ok(exportRequest);
assert.strictEqual(exportRequest.resourceMetrics?.length, 1);
assert.strictEqual(
exportRequest.resourceMetrics?.[0].schemaUrl,
'https://opentelemetry.test/schemas/1.2.3'
);
});
});
describe('ProtobufMetricsSerializer', function () {

View File

@ -232,11 +232,8 @@ describe('Trace', () => {
let resource: Resource;
let span: ReadableSpan;
beforeEach(() => {
resource = resourceFromAttributes({
'resource-attribute': 'resource attribute value',
});
span = {
function createSpanWithResource(spanResource: Resource): ReadableSpan {
return {
spanContext: () => ({
spanId: '0000000000000002',
traceFlags: TraceFlags.SAMPLED,
@ -283,7 +280,7 @@ describe('Trace', () => {
},
],
name: 'span-name',
resource,
resource: spanResource,
startTime: [1640715557, 342725388],
status: {
code: SpanStatusCode.OK,
@ -292,6 +289,13 @@ describe('Trace', () => {
droppedEventsCount: 0,
droppedLinksCount: 0,
};
}
beforeEach(() => {
resource = resourceFromAttributes({
'resource-attribute': 'resource attribute value',
});
span = createSpanWithResource(resource);
});
describe('createExportTraceServiceRequest', () => {
@ -443,6 +447,26 @@ describe('Trace', () => {
);
});
});
it('supports schema URL on resource', () => {
const resourceWithSchema = resourceFromAttributes(
{ 'resource-attribute': 'resource attribute value' },
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
const spanFromSDK = createSpanWithResource(resourceWithSchema);
const exportRequest = createExportTraceServiceRequest([spanFromSDK], {
useHex: true,
});
assert.ok(exportRequest);
assert.strictEqual(exportRequest.resourceSpans?.length, 1);
assert.strictEqual(
exportRequest.resourceSpans?.[0].schemaUrl,
'https://opentelemetry.test/schemas/1.2.3'
);
});
});
describe('ProtobufTracesSerializer', function () {

View File

@ -41,6 +41,11 @@ export interface Resource {
*/
readonly attributes: Attributes;
/**
* @returns the Resource's schema URL or undefined if not set.
*/
readonly schemaUrl?: string;
/**
* Returns a promise that will never be rejected. Resolves when all async attributes have finished being added to
* this Resource's attributes. This is useful in exporters to block until resource detection

View File

@ -29,19 +29,22 @@ import {
DetectedResourceAttributes,
MaybePromise,
RawResourceAttribute,
ResourceOptions,
} from './types';
import { isPromiseLike } from './utils';
class ResourceImpl implements Resource {
private _rawAttributes: RawResourceAttribute[];
private _asyncAttributesPending = false;
private _schemaUrl?: string;
private _memoizedAttributes?: Attributes;
static FromAttributeList(
attributes: [string, MaybePromise<AttributeValue | undefined>][]
attributes: [string, MaybePromise<AttributeValue | undefined>][],
options?: ResourceOptions
): Resource {
const res = new ResourceImpl({});
const res = new ResourceImpl({}, options);
res._rawAttributes = guardedRawAttributes(attributes);
res._asyncAttributesPending =
attributes.filter(([_, val]) => isPromiseLike(val)).length > 0;
@ -54,7 +57,8 @@ class ResourceImpl implements Resource {
* information about the entity as numbers, strings or booleans
* TODO: Consider to add check/validation on attributes.
*/
resource: DetectedResource
resource: DetectedResource,
options?: ResourceOptions
) {
const attributes = resource.attributes ?? {};
this._rawAttributes = Object.entries(attributes).map(([k, v]) => {
@ -67,6 +71,7 @@ class ResourceImpl implements Resource {
});
this._rawAttributes = guardedRawAttributes(this._rawAttributes);
this._schemaUrl = validateSchemaUrl(options?.schemaUrl);
}
public get asyncAttributesPending(): boolean {
@ -120,28 +125,39 @@ class ResourceImpl implements Resource {
return this._rawAttributes;
}
public get schemaUrl(): string | undefined {
return this._schemaUrl;
}
public merge(resource: Resource | null): Resource {
if (resource == null) return this;
// Order is important
// Spec states incoming attributes override existing attributes
return ResourceImpl.FromAttributeList([
...resource.getRawAttributes(),
...this.getRawAttributes(),
]);
const mergedSchemaUrl = mergeSchemaUrl(this, resource);
const mergedOptions: ResourceOptions | undefined = mergedSchemaUrl
? { schemaUrl: mergedSchemaUrl }
: undefined;
return ResourceImpl.FromAttributeList(
[...resource.getRawAttributes(), ...this.getRawAttributes()],
mergedOptions
);
}
}
export function resourceFromAttributes(
attributes: DetectedResourceAttributes
attributes: DetectedResourceAttributes,
options?: ResourceOptions
): Resource {
return ResourceImpl.FromAttributeList(Object.entries(attributes));
return ResourceImpl.FromAttributeList(Object.entries(attributes), options);
}
export function resourceFromDetectedResource(
detectedResource: DetectedResource
detectedResource: DetectedResource,
options?: ResourceOptions
): Resource {
return new ResourceImpl(detectedResource);
return new ResourceImpl(detectedResource, options);
}
export function emptyResource(): Resource {
@ -177,3 +193,48 @@ function guardedRawAttributes(
return [k, v];
});
}
function validateSchemaUrl(schemaUrl?: string): string | undefined {
if (typeof schemaUrl === 'string' || schemaUrl === undefined) {
return schemaUrl;
}
diag.warn(
'Schema URL must be string or undefined, got %s. Schema URL will be ignored.',
schemaUrl
);
return undefined;
}
function mergeSchemaUrl(
old: Resource,
updating: Resource | null
): string | undefined {
const oldSchemaUrl = old?.schemaUrl;
const updatingSchemaUrl = updating?.schemaUrl;
const isOldEmpty = oldSchemaUrl === undefined || oldSchemaUrl === '';
const isUpdatingEmpty =
updatingSchemaUrl === undefined || updatingSchemaUrl === '';
if (isOldEmpty) {
return updatingSchemaUrl;
}
if (isUpdatingEmpty) {
return oldSchemaUrl;
}
if (oldSchemaUrl === updatingSchemaUrl) {
return oldSchemaUrl;
}
diag.warn(
'Schema URL merge conflict: old resource has "%s", updating resource has "%s". Resulting resource will have undefined Schema URL.',
oldSchemaUrl,
updatingSchemaUrl
);
return undefined;
}

View File

@ -59,3 +59,10 @@ export type RawResourceAttribute = [
string,
MaybePromise<AttributeValue | undefined>,
];
/**
* Options for creating a {@link Resource}.
*/
export type ResourceOptions = {
schemaUrl?: string;
};

View File

@ -287,6 +287,192 @@ describe('Resource', () => {
});
});
describe('schema URL support', () => {
it('should create resource with schema URL', () => {
const schemaUrl = 'https://example.test/schemas/1.2.3';
const resource = resourceFromAttributes({ attr: 'value' }, { schemaUrl });
assert.strictEqual(resource.schemaUrl, schemaUrl);
});
it('should create resource without schema URL', () => {
const resource = resourceFromAttributes({ attr: 'value' });
assert.strictEqual(resource.schemaUrl, undefined);
});
it('should retain schema URL from base resource when other has no schema URL', () => {
const schemaUrl = 'https://opentelemetry.test/schemas/1.2.3';
const resource1 = resourceFromAttributes(
{ attr1: 'value1' },
{ schemaUrl }
);
const resource2 = resourceFromAttributes({ attr2: 'value2' });
const mergedResource = resource1.merge(resource2);
assert.strictEqual(mergedResource.schemaUrl, schemaUrl);
});
it('should retain schema URL from other resource when base has no schema URL', () => {
const resource1 = resourceFromAttributes({ attr1: 'value1' });
const resource2 = resourceFromAttributes(
{ attr2: 'value2' },
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
const mergedResource = resource1.merge(resource2);
assert.strictEqual(
mergedResource.schemaUrl,
'https://opentelemetry.test/schemas/1.2.3'
);
});
it('should have empty schema URL when merging resources with no schema URL', () => {
const resource1 = resourceFromAttributes(
{ attr1: 'value1' },
{ schemaUrl: '' }
);
const resource2 = resourceFromAttributes(
{ attr2: 'value2' },
{ schemaUrl: '' }
);
const mergedResource = resource1.merge(resource2);
assert.strictEqual(mergedResource.schemaUrl, undefined);
});
it('should maintain backward compatibility - schemaUrl is optional', () => {
const resource = emptyResource();
const schemaUrl = resource.schemaUrl;
assert.strictEqual(schemaUrl, undefined);
});
it('should work with async attributes and schema URLs', async () => {
const resource = resourceFromAttributes(
{
sync: 'fromsync',
async: new Promise(resolve =>
setTimeout(() => resolve('fromasync'), 1)
),
},
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
await resource.waitForAsyncAttributes?.();
assert.deepStrictEqual(resource.attributes, {
sync: 'fromsync',
async: 'fromasync',
});
assert.strictEqual(
resource.schemaUrl,
'https://opentelemetry.test/schemas/1.2.3'
);
});
it('should merge schema URLs according to OpenTelemetry spec - same URLs', () => {
const resource1 = resourceFromAttributes(
{ attr1: 'value1' },
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
const resource2 = resourceFromAttributes(
{ attr2: 'value2' },
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
const mergedResource = resource1.merge(resource2);
assert.strictEqual(
mergedResource.schemaUrl,
'https://opentelemetry.test/schemas/1.2.3'
);
});
it('should merge schema URLs according to OpenTelemetry spec - conflict case (undefined behavior)', () => {
const warnStub = sinon.spy(diag, 'warn');
const resource1 = resourceFromAttributes(
{ attr1: 'value1' },
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.3' }
);
const resource2 = resourceFromAttributes(
{ attr2: 'value2' },
{ schemaUrl: 'https://opentelemetry.test/schemas/1.2.4' }
);
const mergedResource = resource1.merge(resource2);
// Implementation-specific: we return undefined to indicate error state
// This aligns with Go, Java, and PHP SDKs which return null/empty for conflicts
assert.strictEqual(mergedResource.schemaUrl, undefined);
assert.ok(warnStub.calledWithMatch('Schema URL merge conflict'));
warnStub.restore();
});
it('should accept valid schema URL formats', () => {
const validSchemaUrls = [
'https://opentelemetry.test/schemas/1.2.3',
'http://example.test/schema',
'https://schemas.opentelemetry.test/path/to/schema/1.21.0',
'https://example.test:8080/path/to/schema',
];
validSchemaUrls.forEach(validUrl => {
const resource = resourceFromAttributes(
{ attr: 'value' },
{ schemaUrl: validUrl }
);
assert.strictEqual(
resource.schemaUrl,
validUrl,
`Expected valid schema URL to be preserved: ${validUrl}`
);
});
});
it('should handle invalid schema URL formats gracefully', () => {
const warnStub = sinon.spy(diag, 'warn');
const invalidSchemaUrls = [
null,
123,
12345678901234567890n,
{ schemaUrl: 'http://example.test/schema' },
['http://example.test/schema'],
];
invalidSchemaUrls.forEach(invalidUrl => {
const resource = resourceFromAttributes(
{ attr: 'value' },
// @ts-expect-error the function signature doesn't allow these, but can still happen at runtime
{ schemaUrl: invalidUrl }
);
// Invalid schema URLs should be ignored (set to undefined)
assert.strictEqual(
resource.schemaUrl,
undefined,
`Expected undefined for invalid schema URL: ${invalidUrl}`
);
});
// Should have logged warnings for each invalid URL
assert.strictEqual(warnStub.callCount, invalidSchemaUrls.length);
assert.ok(
warnStub.alwaysCalledWithMatch('Schema URL must be string or undefined')
);
warnStub.restore();
});
});
describeNode('.default()', () => {
it('should return a default resource', () => {
const resource = defaultResource();