From 6dd518dab1253a9472f7e4348c4382f53973fffb Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:07:07 -0800 Subject: [PATCH 1/4] OTel Web SDK Phase 1 --- .../otel-core/Tests/Unit/src/index.tests.ts | 2 + .../Tests/Unit/src/sdk/OTelWebSdk.Tests.ts | 1106 +++++++++++++++++ shared/otel-core/src/index.ts | 8 +- .../otel-core/src/interfaces/otel/IOTelSdk.ts | 12 - .../src/interfaces/otel/IOTelSdkCtx.ts | 57 - .../src/interfaces/otel/IOTelWebSdk.ts | 126 ++ .../otel/config/IOTelWebSdkConfig.ts | 136 ++ .../interfaces/otel/trace/IOTelTracerCtx.ts | 2 +- .../otel-core/src/otel/api/context/context.ts | 4 +- shared/otel-core/src/otel/sdk/OTelSdk.ts | 263 ---- shared/otel-core/src/otel/sdk/OTelWebSdk.ts | 446 +++++++ shared/otel-core/src/utils/DataCacheHelper.ts | 2 +- 12 files changed, 1825 insertions(+), 339 deletions(-) create mode 100644 shared/otel-core/Tests/Unit/src/sdk/OTelWebSdk.Tests.ts delete mode 100644 shared/otel-core/src/interfaces/otel/IOTelSdk.ts delete mode 100644 shared/otel-core/src/interfaces/otel/IOTelSdkCtx.ts create mode 100644 shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts create mode 100644 shared/otel-core/src/interfaces/otel/config/IOTelWebSdkConfig.ts delete mode 100644 shared/otel-core/src/otel/sdk/OTelSdk.ts create mode 100644 shared/otel-core/src/otel/sdk/OTelWebSdk.ts diff --git a/shared/otel-core/Tests/Unit/src/index.tests.ts b/shared/otel-core/Tests/Unit/src/index.tests.ts index c81be1895..eca5a8c03 100644 --- a/shared/otel-core/Tests/Unit/src/index.tests.ts +++ b/shared/otel-core/Tests/Unit/src/index.tests.ts @@ -7,6 +7,7 @@ import { OTelMultiLogRecordProcessorTests } from "./sdk/OTelMultiLogRecordProces import { CommonUtilsTests } from "./sdk/commonUtils.Tests"; import { OpenTelemetryErrorsTests } from "./ai/errors.Tests"; import { OTelTraceApiTests } from "./trace/traceState.Tests"; +import { OTelWebSdkTests } from "./sdk/OTelWebSdk.Tests"; // AppInsightsCommon tests import { ApplicationInsightsTests } from "./ai/AppInsightsCommon.tests"; @@ -47,6 +48,7 @@ export function runTests() { new CommonUtilsTests().registerTests(); new OpenTelemetryErrorsTests().registerTests(); new OTelTraceApiTests().registerTests(); + new OTelWebSdkTests().registerTests(); new GlobalTestHooks().registerTests(); new DynamicTests().registerTests(); diff --git a/shared/otel-core/Tests/Unit/src/sdk/OTelWebSdk.Tests.ts b/shared/otel-core/Tests/Unit/src/sdk/OTelWebSdk.Tests.ts new file mode 100644 index 000000000..6003865e5 --- /dev/null +++ b/shared/otel-core/Tests/Unit/src/sdk/OTelWebSdk.Tests.ts @@ -0,0 +1,1106 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { createPromise, IPromise } from "@nevware21/ts-async"; + +import { createOTelWebSdk } from "../../../../src/otel/sdk/OTelWebSdk"; +import { IOTelWebSdkConfig } from "../../../../src/interfaces/otel/config/IOTelWebSdkConfig"; +import { IOTelWebSdk } from "../../../../src/interfaces/otel/IOTelWebSdk"; +import { IOTelErrorHandlers } from "../../../../src/interfaces/otel/config/IOTelErrorHandlers"; +import { IOTelResource, OTelRawResourceAttribute } from "../../../../src/interfaces/otel/resources/IOTelResource"; +import { IOTelContextManager } from "../../../../src/interfaces/otel/context/IOTelContextManager"; +import { IOTelIdGenerator } from "../../../../src/interfaces/otel/trace/IOTelIdGenerator"; +import { IOTelSampler } from "../../../../src/interfaces/otel/trace/IOTelSampler"; +import { IOTelLogRecordProcessor } from "../../../../src/interfaces/otel/logs/IOTelLogRecordProcessor"; +import { IOTelAttributes } from "../../../../src/interfaces/otel/IOTelAttributes"; +import { createResolvedPromise } from "@nevware21/ts-async"; +import { createContext } from "../../../../src/otel/api/context/context"; +import { eOTelSamplingDecision } from "../../../../src/enums/otel/OTelSamplingDecision"; +import { eOTelSpanKind } from "../../../../src/enums/otel/OTelSpanKind"; +import { eW3CTraceFlags } from "../../../../src/enums/W3CTraceFlags"; +import { IOTelSamplingResult } from "../../../../src/interfaces/otel/trace/IOTelSamplingResult"; +import { createContextManager } from "../../../../src/otel/api/context/contextManager"; +import { IReadableSpan } from "../../../../src/interfaces/otel/trace/IReadableSpan"; + +export class OTelWebSdkTests extends AITestClass { + private _sdk: IOTelWebSdk | null = null; + + public testInitialize() { + super.testInitialize(); + this._sdk = null; + } + + public testCleanup() { + if (this._sdk) { + this._sdk.shutdown(); + this._sdk = null; + } + super.testCleanup(); + } + + public registerTests() { + this._registerConstructionTests(); + this._registerValidationTests(); + this._registerTracerTests(); + this._registerSpanCreationTests(); + this._registerStartActiveSpanTests(); + this._registerSamplingTests(); + this._registerLoggerTests(); + this._registerShutdownTests(); + this._registerForceFlushTests(); + this._registerConfigTests(); + } + + private _registerConstructionTests(): void { + this.testCase({ + name: "OTelWebSdk: createOTelWebSdk should create an instance with valid config", + test: () => { + let config = this._createValidConfig(); + this._sdk = createOTelWebSdk(config); + Assert.ok(this._sdk, "SDK instance should be created"); + Assert.equal(typeof this._sdk.getTracer, "function", "Should have getTracer method"); + Assert.equal(typeof this._sdk.getLogger, "function", "Should have getLogger method"); + Assert.equal(typeof this._sdk.forceFlush, "function", "Should have forceFlush method"); + Assert.equal(typeof this._sdk.shutdown, "function", "Should have shutdown method"); + Assert.equal(typeof this._sdk.getConfig, "function", "Should have getConfig method"); + } + }); + + this.testCase({ + name: "OTelWebSdk: should support multiple independent instances", + test: () => { + let config1 = this._createValidConfig(); + let config2 = this._createValidConfig(); + let sdk1 = createOTelWebSdk(config1); + let sdk2 = createOTelWebSdk(config2); + + Assert.ok(sdk1, "First SDK instance should be created"); + Assert.ok(sdk2, "Second SDK instance should be created"); + Assert.notEqual(sdk1, sdk2, "SDK instances should be different objects"); + + // Each can provide independent tracers + let tracer1 = sdk1.getTracer("service-a"); + let tracer2 = sdk2.getTracer("service-b"); + Assert.ok(tracer1, "First SDK should provide a tracer"); + Assert.ok(tracer2, "Second SDK should provide a tracer"); + + sdk1.shutdown(); + sdk2.shutdown(); + } + }); + } + + private _registerValidationTests(): void { + this.testCase({ + name: "OTelWebSdk: should call error handler when resource is missing", + test: () => { + let errorCalled = false; + let config = this._createValidConfig(); + config.errorHandlers = { + error: function (msg) { + errorCalled = true; + } + }; + (config as any).resource = null; + this._sdk = createOTelWebSdk(config); + Assert.ok(errorCalled, "Error handler should be called for missing resource"); + } + }); + + this.testCase({ + name: "OTelWebSdk: should call error handler when contextManager is missing", + test: () => { + let errorCalled = false; + let config = this._createValidConfig(); + config.errorHandlers = { + error: function (msg) { + errorCalled = true; + } + }; + (config as any).contextManager = null; + this._sdk = createOTelWebSdk(config); + Assert.ok(errorCalled, "Error handler should be called for missing contextManager"); + } + }); + + this.testCase({ + name: "OTelWebSdk: should call error handler when idGenerator is missing", + test: () => { + let errorCalled = false; + let config = this._createValidConfig(); + config.errorHandlers = { + error: function (msg) { + errorCalled = true; + } + }; + (config as any).idGenerator = null; + this._sdk = createOTelWebSdk(config); + Assert.ok(errorCalled, "Error handler should be called for missing idGenerator"); + } + }); + + this.testCase({ + name: "OTelWebSdk: should call error handler when sampler is missing", + test: () => { + let errorCalled = false; + let config = this._createValidConfig(); + config.errorHandlers = { + error: function (msg) { + errorCalled = true; + } + }; + (config as any).sampler = null; + this._sdk = createOTelWebSdk(config); + Assert.ok(errorCalled, "Error handler should be called for missing sampler"); + } + }); + + this.testCase({ + name: "OTelWebSdk: should call error handler when performanceNow is missing", + test: () => { + let errorCalled = false; + let config = this._createValidConfig(); + config.errorHandlers = { + error: function (msg) { + errorCalled = true; + } + }; + (config as any).performanceNow = null; + this._sdk = createOTelWebSdk(config); + Assert.ok(errorCalled, "Error handler should be called for missing performanceNow"); + } + }); + + this.testCase({ + name: "OTelWebSdk: should warn when errorHandlers are missing", + test: () => { + let warnCalled = false; + let origWarn = console.warn; + console.warn = function () { + warnCalled = true; + }; + try { + let config = this._createValidConfig(); + (config as any).errorHandlers = null; + this._sdk = createOTelWebSdk(config); + Assert.ok(warnCalled, "Should warn about missing errorHandlers"); + } finally { + console.warn = origWarn; + } + } + }); + } + + private _registerTracerTests(): void { + this.testCase({ + name: "OTelWebSdk: getTracer should return a tracer instance", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + Assert.ok(tracer, "Should return a tracer"); + Assert.equal(typeof tracer.startSpan, "function", "Tracer should have startSpan method"); + Assert.equal(typeof tracer.startActiveSpan, "function", "Tracer should have startActiveSpan method"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getTracer should cache tracers by name", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer1 = this._sdk.getTracer("test-tracer"); + let tracer2 = this._sdk.getTracer("test-tracer"); + Assert.equal(tracer1, tracer2, "Same name should return same tracer instance"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getTracer should cache tracers by name and version", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer1 = this._sdk.getTracer("test-tracer", "1.0.0"); + let tracer2 = this._sdk.getTracer("test-tracer", "1.0.0"); + let tracer3 = this._sdk.getTracer("test-tracer", "2.0.0"); + Assert.equal(tracer1, tracer2, "Same name and version should return same tracer instance"); + Assert.notEqual(tracer1, tracer3, "Different versions should return different tracer instances"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getTracer should return different tracers for different names", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer1 = this._sdk.getTracer("service-a"); + let tracer2 = this._sdk.getTracer("service-b"); + Assert.notEqual(tracer1, tracer2, "Different names should return different tracer instances"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getTracer should handle schemaUrl in options", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer1 = this._sdk.getTracer("test-tracer", "1.0.0", { schemaUrl: "https://example.com/v1" }); + let tracer2 = this._sdk.getTracer("test-tracer", "1.0.0", { schemaUrl: "https://example.com/v2" }); + let tracer3 = this._sdk.getTracer("test-tracer", "1.0.0", { schemaUrl: "https://example.com/v1" }); + Assert.notEqual(tracer1, tracer2, "Different schemaUrls should return different tracers"); + Assert.equal(tracer1, tracer3, "Same schemaUrl should return same tracer"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getTracer should use 'unknown' for empty name", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer(""); + Assert.ok(tracer, "Should return a tracer even for empty name"); + } + }); + } + + private _registerSpanCreationTests(): void { + this.testCase({ + name: "OTelWebSdk: startSpan should create a functional span", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer", "1.0.0"); + let result = tracer.startSpan("test-operation"); + Assert.ok(result, "startSpan should return a span (not null)"); + + let span = result as IReadableSpan; + Assert.equal(span.name, "test-operation", "Span name should match"); + Assert.equal(typeof span.spanContext, "function", "Span should have spanContext method"); + Assert.equal(typeof span.setAttribute, "function", "Span should have setAttribute method"); + Assert.equal(typeof span.setAttributes, "function", "Span should have setAttributes method"); + Assert.equal(typeof span.setStatus, "function", "Span should have setStatus method"); + Assert.equal(typeof span.end, "function", "Span should have end method"); + Assert.equal(typeof span.isRecording, "function", "Span should have isRecording method"); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should generate valid trace and span IDs", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + let spanContext = span.spanContext(); + Assert.ok(spanContext, "Span should have a spanContext"); + Assert.ok(spanContext.traceId, "Span context should have a traceId"); + Assert.ok(spanContext.spanId, "Span context should have a spanId"); + Assert.equal(spanContext.traceId.length, 32, "traceId should be 32 hex chars"); + Assert.equal(spanContext.spanId.length, 16, "spanId should be 16 hex chars"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should create recording spans with AlwaysOn sampler", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.ok(span.isRecording(), "Span should be recording with AlwaysOn sampler"); + Assert.equal(span.spanContext().traceFlags, eW3CTraceFlags.Sampled, "Trace flags should indicate sampled"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should set span kind from options", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation", { + kind: eOTelSpanKind.CLIENT + }) as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.equal(span.kind, eOTelSpanKind.CLIENT, "Span kind should match options"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should default to INTERNAL span kind", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.equal(span.kind, eOTelSpanKind.INTERNAL, "Default span kind should be INTERNAL"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should set attributes from options", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation", { + attributes: { "key1": "value1", "key2": 42 } + }) as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.ok(span.attributes, "Span should have attributes"); + Assert.equal(span.attributes["key1"], "value1", "Should have key1 attribute"); + Assert.equal(span.attributes["key2"], 42, "Should have key2 attribute"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should support setAttribute after creation", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + span.setAttribute("dynamic.key", "dynamic.value"); + Assert.equal(span.attributes["dynamic.key"], "dynamic.value", "Should have dynamically set attribute"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should create different spans with different IDs", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span1 = tracer.startSpan("operation-1") as IReadableSpan; + let span2 = tracer.startSpan("operation-2") as IReadableSpan; + Assert.ok(span1, "Span 1 should not be null"); + Assert.ok(span2, "Span 2 should not be null"); + + Assert.notEqual( + span1.spanContext().spanId, + span2.spanContext().spanId, + "Different spans should have different spanIds" + ); + + span1.end(); + span2.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan with root option should create new trace", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span1 = tracer.startSpan("root-1") as IReadableSpan; + let span2 = tracer.startSpan("root-2", { root: true }) as IReadableSpan; + Assert.ok(span1, "Span 1 should not be null"); + Assert.ok(span2, "Span 2 should not be null"); + + // Both are root spans (no active context), so they both get new traceIds + let traceId1 = span1.spanContext().traceId; + let traceId2 = span2.spanContext().traceId; + + Assert.ok(traceId1, "First span should have a traceId"); + Assert.ok(traceId2, "Second span should have a traceId"); + Assert.notEqual(traceId1, traceId2, "Root spans should have different traceIds"); + + span1.end(); + span2.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: span end should mark span as ended", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.ok(span.isRecording(), "Span should be recording before end"); + Assert.ok(!span.ended, "Span should not be ended before end()"); + + span.end(); + + Assert.ok(!span.isRecording(), "Span should not be recording after end"); + Assert.ok(span.ended, "Span should be ended after end()"); + } + }); + + this.testCase({ + name: "OTelWebSdk: startSpan should return null after shutdown", + test: (): IPromise => { + let sdk = createOTelWebSdk(this._createValidConfig()); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.shutdown().then(function () { + try { + let tracer = sdk.getTracer("test"); + let span = tracer.startSpan("test-span"); + Assert.equal(span, null, "startSpan after shutdown should return null"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }); + } + }); + } + + private _registerStartActiveSpanTests(): void { + this.testCase({ + name: "OTelWebSdk: startActiveSpan should execute callback with span", + test: () => { + let config = this._createValidConfig(); + config.contextManager = createContextManager(); + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let callbackExecuted = false; + + tracer.startActiveSpan("active-operation", function (span) { + callbackExecuted = true; + Assert.ok(span, "Callback should receive a span"); + let readable = span as IReadableSpan; + Assert.equal(readable.name, "active-operation", "Span name should match"); + Assert.ok(span.isRecording(), "Span should be recording"); + span.end(); + }); + + Assert.ok(callbackExecuted, "Callback should have been executed"); + } + }); + + this.testCase({ + name: "OTelWebSdk: startActiveSpan should return callback result", + test: () => { + let config = this._createValidConfig(); + config.contextManager = createContextManager(); + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + + let result = tracer.startActiveSpan("active-operation", function (span) { + span.end(); + return 42; + }); + + Assert.equal(result, 42, "Should return the callback result"); + } + }); + + this.testCase({ + name: "OTelWebSdk: startActiveSpan with options should pass options to span", + test: () => { + let config = this._createValidConfig(); + config.contextManager = createContextManager(); + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + + tracer.startActiveSpan("active-operation", { kind: eOTelSpanKind.SERVER }, function (span) { + let readable = span as IReadableSpan; + Assert.equal(readable.kind, eOTelSpanKind.SERVER, "Span should have the specified kind"); + span.end(); + }); + } + }); + + this.testCase({ + name: "OTelWebSdk: startActiveSpan should set span as parent for nested spans", + test: () => { + let config = this._createValidConfig(); + config.contextManager = createContextManager(); + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let parentTraceId: string = ""; + let childTraceId: string = ""; + let childParentSpanId: string = ""; + let parentSpanId: string = ""; + + tracer.startActiveSpan("parent-operation", function (parentSpan) { + parentTraceId = parentSpan.spanContext().traceId; + parentSpanId = parentSpan.spanContext().spanId; + + // Create a child span while parent is active + let childResult = tracer.startSpan("child-operation"); + Assert.ok(childResult, "Child span should not be null"); + let childSpan = childResult as IReadableSpan; + childTraceId = childSpan.spanContext().traceId; + childParentSpanId = childSpan.parentSpanId || ""; + + childSpan.end(); + parentSpan.end(); + }); + + Assert.equal(childTraceId, parentTraceId, "Child should inherit parent's traceId"); + Assert.equal(childParentSpanId, parentSpanId, "Child's parentSpanId should be parent's spanId"); + } + }); + + this.testCase({ + name: "OTelWebSdk: nested startActiveSpan should create proper hierarchy", + test: () => { + let config = this._createValidConfig(); + config.contextManager = createContextManager(); + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let grandparentSpanId: string = ""; + let parentSpanId: string = ""; + let childParentSpanId: string = ""; + let traceId: string = ""; + + tracer.startActiveSpan("grandparent", function (gp) { + traceId = gp.spanContext().traceId; + grandparentSpanId = gp.spanContext().spanId; + + tracer.startActiveSpan("parent", function (parent) { + let parentReadable = parent as IReadableSpan; + parentSpanId = parent.spanContext().spanId; + Assert.equal( + parent.spanContext().traceId, traceId, + "Parent should share grandparent's traceId" + ); + Assert.equal( + parentReadable.parentSpanId, grandparentSpanId, + "Parent's parentSpanId should be grandparent's spanId" + ); + + let childResult = tracer.startSpan("child"); + Assert.ok(childResult, "Child span should not be null"); + let child = childResult as IReadableSpan; + childParentSpanId = child.parentSpanId || ""; + Assert.equal( + child.spanContext().traceId, traceId, + "Child should share the same traceId" + ); + + child.end(); + parent.end(); + }); + + gp.end(); + }); + + Assert.equal(childParentSpanId, parentSpanId, "Child's parent should be the active parent span"); + } + }); + } + + private _registerSamplingTests(): void { + this.testCase({ + name: "OTelWebSdk: NOT_RECORD sampler should create non-recording spans", + test: () => { + let config = this._createValidConfig(); + config.sampler = { + shouldSample: function (): IOTelSamplingResult { + return { decision: eOTelSamplingDecision.NOT_RECORD }; + }, + toString: function () { return "NeverSampler"; } + }; + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should still be created (non-recording)"); + + Assert.ok(!span.isRecording(), "Span should NOT be recording with NOT_RECORD decision"); + Assert.equal(span.spanContext().traceFlags, eW3CTraceFlags.None, "Trace flags should be None"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: RECORD sampler should create recording but not sampled spans", + test: () => { + let config = this._createValidConfig(); + config.sampler = { + shouldSample: function (): IOTelSamplingResult { + return { decision: eOTelSamplingDecision.RECORD }; + }, + toString: function () { return "RecordOnlySampler"; } + }; + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.ok(span.isRecording(), "Span should be recording with RECORD decision"); + Assert.equal(span.spanContext().traceFlags, eW3CTraceFlags.None, "Trace flags should be None (not sampled)"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: RECORD_AND_SAMPLED sampler should create sampled spans", + test: () => { + let config = this._createValidConfig(); + config.sampler = { + shouldSample: function (): IOTelSamplingResult { + return { decision: eOTelSamplingDecision.RECORD_AND_SAMPLED }; + }, + toString: function () { return "AlwaysOnSampler"; } + }; + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation") as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.ok(span.isRecording(), "Span should be recording"); + Assert.equal(span.spanContext().traceFlags, eW3CTraceFlags.Sampled, "Trace flags should indicate sampled"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: sampler should receive span name and kind", + test: () => { + let receivedSpanName: string = ""; + let receivedSpanKind: number = -1; + let config = this._createValidConfig(); + config.sampler = { + shouldSample: function (_ctx: any, _traceId: string, spanName: string, spanKind: number): IOTelSamplingResult { + receivedSpanName = spanName; + receivedSpanKind = spanKind; + return { decision: eOTelSamplingDecision.RECORD_AND_SAMPLED }; + }, + toString: function () { return "TestSampler"; } + }; + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("my-operation", { kind: eOTelSpanKind.SERVER }) as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.equal(receivedSpanName, "my-operation", "Sampler should receive the span name"); + Assert.equal(receivedSpanKind, eOTelSpanKind.SERVER, "Sampler should receive the span kind"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: sampler-provided attributes should be merged into span", + test: () => { + let config = this._createValidConfig(); + config.sampler = { + shouldSample: function (): IOTelSamplingResult { + return { + decision: eOTelSamplingDecision.RECORD_AND_SAMPLED, + attributes: { "sampler.key": "sampler.value" } + }; + }, + toString: function () { return "AttributeSampler"; } + }; + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation", { + attributes: { "user.key": "user.value" } + }) as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.equal(span.attributes["sampler.key"], "sampler.value", "Should include sampler attribute"); + Assert.equal(span.attributes["user.key"], "user.value", "Should include user attribute"); + + span.end(); + } + }); + + this.testCase({ + name: "OTelWebSdk: user attributes should override sampler attributes", + test: () => { + let config = this._createValidConfig(); + config.sampler = { + shouldSample: function (): IOTelSamplingResult { + return { + decision: eOTelSamplingDecision.RECORD_AND_SAMPLED, + attributes: { "shared.key": "sampler-wins" } + }; + }, + toString: function () { return "OverrideSampler"; } + }; + this._sdk = createOTelWebSdk(config); + let tracer = this._sdk.getTracer("test-tracer"); + let span = tracer.startSpan("test-operation", { + attributes: { "shared.key": "user-wins" } + }) as IReadableSpan; + Assert.ok(span, "Span should not be null"); + + Assert.equal(span.attributes["shared.key"], "user-wins", "User attributes should override sampler attributes"); + + span.end(); + } + }); + } + + private _registerLoggerTests(): void { + this.testCase({ + name: "OTelWebSdk: getLogger should return a logger instance", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let logger = this._sdk.getLogger("test-logger"); + Assert.ok(logger, "Should return a logger"); + Assert.equal(typeof logger.emit, "function", "Logger should have emit method"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getLogger should return loggers from the logger provider", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let logger1 = this._sdk.getLogger("test-logger", "1.0.0"); + let logger2 = this._sdk.getLogger("test-logger", "1.0.0"); + Assert.ok(logger1, "Should return first logger"); + Assert.ok(logger2, "Should return second logger"); + Assert.equal(logger1, logger2, "Same name and version should return same logger"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getLogger should return different loggers for different names", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let logger1 = this._sdk.getLogger("logger-a"); + let logger2 = this._sdk.getLogger("logger-b"); + Assert.ok(logger1, "First logger should exist"); + Assert.ok(logger2, "Second logger should exist"); + Assert.notEqual(logger1, logger2, "Different names should return different loggers"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getLogger should not throw when calling emit", + test: () => { + this._sdk = createOTelWebSdk(this._createValidConfig()); + let logger = this._sdk.getLogger("test-logger"); + let threw = false; + try { + logger.emit({ body: "test message" }); + } catch (e) { + threw = true; + } + Assert.ok(!threw, "emit should not throw"); + } + }); + } + + private _registerShutdownTests(): void { + this.testCase({ + name: "OTelWebSdk: shutdown should resolve successfully", + test: (): IPromise => { + let sdk = createOTelWebSdk(this._createValidConfig()); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.shutdown().then(function () { + try { + Assert.ok(true, "Shutdown should resolve successfully"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }); + } + }); + + this.testCase({ + name: "OTelWebSdk: getTracer after shutdown should return no-op tracer", + test: (): IPromise => { + let sdk = createOTelWebSdk(this._createValidConfig()); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.shutdown().then(function () { + try { + let tracer = sdk.getTracer("test"); + Assert.ok(tracer, "Should return a tracer (no-op) after shutdown"); + Assert.equal(typeof tracer.startSpan, "function", "No-op tracer should have startSpan"); + Assert.equal(typeof tracer.startActiveSpan, "function", "No-op tracer should have startActiveSpan"); + // Verify the no-op tracer returns null for startSpan + let span = tracer.startSpan("test-span"); + Assert.equal(span, null, "No-op tracer startSpan should return null"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }); + } + }); + + this.testCase({ + name: "OTelWebSdk: getLogger after shutdown should return no-op logger", + test: (): IPromise => { + let sdk = createOTelWebSdk(this._createValidConfig()); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.shutdown().then(function () { + try { + let logger = sdk.getLogger("test"); + Assert.ok(logger, "Should return a logger (no-op) after shutdown"); + // Verify the no-op logger does not throw + let threw = false; + try { + logger.emit({ body: "after shutdown" }); + } catch (e) { + threw = true; + } + Assert.ok(!threw, "No-op logger emit should not throw"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }); + } + }); + + this.testCase({ + name: "OTelWebSdk: second shutdown should resolve without error", + test: (): IPromise => { + let sdk = createOTelWebSdk(this._createValidConfig()); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.shutdown().then(function () { + sdk.shutdown().then(function () { + try { + Assert.ok(true, "Second shutdown should resolve successfully"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }).catch(reject); + }); + } + }); + + this.testCase({ + name: "OTelWebSdk: shutdown should warn on second call", + test: (): IPromise => { + let warnCalled = false; + let config = this._createValidConfig(); + config.errorHandlers = { + warn: function () { + warnCalled = true; + } + }; + let sdk = createOTelWebSdk(config); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.shutdown().then(function () { + sdk.shutdown().then(function () { + try { + Assert.ok(warnCalled, "Should warn on second shutdown call"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }).catch(reject); + }); + } + }); + } + + private _registerForceFlushTests(): void { + this.testCase({ + name: "OTelWebSdk: forceFlush should resolve successfully", + test: (): IPromise => { + let sdk = createOTelWebSdk(this._createValidConfig()); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.forceFlush().then(function () { + try { + Assert.ok(true, "forceFlush should resolve successfully"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }); + } + }); + + this.testCase({ + name: "OTelWebSdk: forceFlush after shutdown should not throw", + test: (): IPromise => { + let sdk = createOTelWebSdk(this._createValidConfig()); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.shutdown().then(function () { + sdk.forceFlush().then(function () { + try { + Assert.ok(true, "forceFlush after shutdown should resolve"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }).catch(reject); + }); + } + }); + + this.testCase({ + name: "OTelWebSdk: forceFlush with log processors should invoke processor flush", + test: (): IPromise => { + let flushCalled = false; + let processor = this._createMockLogProcessor(); + processor.forceFlush = function () { + flushCalled = true; + return createResolvedPromise(undefined); + }; + let config = this._createValidConfig(); + config.logProcessors = [processor]; + let sdk = createOTelWebSdk(config); + this._sdk = sdk; + return createPromise(function (resolve, reject) { + sdk.forceFlush().then(function () { + try { + Assert.ok(flushCalled, "forceFlush should invoke processor forceFlush"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }); + } + }); + } + + private _registerConfigTests(): void { + this.testCase({ + name: "OTelWebSdk: getConfig should return the config object", + test: () => { + let config = this._createValidConfig(); + this._sdk = createOTelWebSdk(config); + let returnedConfig = this._sdk.getConfig(); + Assert.ok(returnedConfig, "getConfig should return a config object"); + Assert.equal(returnedConfig.resource, config.resource, "Config resource should match"); + Assert.equal(returnedConfig.contextManager, config.contextManager, "Config contextManager should match"); + Assert.equal(returnedConfig.idGenerator, config.idGenerator, "Config idGenerator should match"); + Assert.equal(returnedConfig.sampler, config.sampler, "Config sampler should match"); + Assert.equal(returnedConfig.performanceNow, config.performanceNow, "Config performanceNow should match"); + } + }); + + this.testCase({ + name: "OTelWebSdk: getConfig should return same config reference (not a copy)", + test: () => { + let config = this._createValidConfig(); + this._sdk = createOTelWebSdk(config); + let returnedConfig = this._sdk.getConfig(); + Assert.equal(returnedConfig, config, "getConfig should return the same config reference"); + } + }); + } + + // ============================= + // Helper methods + // ============================= + + private _createValidConfig(): IOTelWebSdkConfig { + return { + resource: this._createMockResource(), + errorHandlers: this._createMockErrorHandlers(), + contextManager: this._createMockContextManager(), + idGenerator: this._createMockIdGenerator(), + sampler: this._createMockSampler(), + performanceNow: function () { return Date.now(); }, + logProcessors: [] + }; + } + + private _createMockResource(): IOTelResource { + let rawAttributes: OTelRawResourceAttribute[] = [ + ["service.name", "test-service"], + ["service.version", "1.0.0"] + ]; + + let resource: IOTelResource = { + attributes: { "service.name": "test-service", "service.version": "1.0.0" } as IOTelAttributes, + merge: function (other: IOTelResource) { + return resource; + }, + getRawAttributes: function () { + return rawAttributes; + } + }; + + return resource; + } + + private _createMockErrorHandlers(): IOTelErrorHandlers { + return { + error: function (_msg: string) { /* noop */ }, + warn: function (_msg: string) { /* noop */ }, + debug: function (_msg: string) { /* noop */ } + }; + } + + private _createMockContextManager(): IOTelContextManager { + return { + active: function () { return null as any; }, + with: function (context: any, fn: any, thisArg?: any): any { + return fn.apply(thisArg, []); + }, + bind: function (context: any, target: T): T { + return target; + }, + enable: function () { return this; }, + disable: function () { return this; } + } as IOTelContextManager; + } + + private _createMockIdGenerator(): IOTelIdGenerator { + let traceCounter = 0; + let spanCounter = 0; + return { + generateTraceId: function () { + traceCounter++; + // 32 hex chars + let hex = traceCounter.toString(16); + while (hex.length < 32) { + hex = "0" + hex; + } + return hex; + }, + generateSpanId: function () { + spanCounter++; + // 16 hex chars + let hex = spanCounter.toString(16); + while (hex.length < 16) { + hex = "0" + hex; + } + return hex; + } + }; + } + + private _createMockSampler(): IOTelSampler { + return { + shouldSample: function (): IOTelSamplingResult { + return { + decision: eOTelSamplingDecision.RECORD_AND_SAMPLED + }; + }, + toString: function () { + return "AlwaysOnSampler"; + } + }; + } + + private _createMockLogProcessor(): IOTelLogRecordProcessor { + return { + onEmit: function () { /* noop */ }, + forceFlush: function () { return createResolvedPromise(undefined); }, + shutdown: function () { return createResolvedPromise(undefined); } + }; + } +} diff --git a/shared/otel-core/src/index.ts b/shared/otel-core/src/index.ts index 0d05a7e04..f83afe104 100644 --- a/shared/otel-core/src/index.ts +++ b/shared/otel-core/src/index.ts @@ -171,7 +171,6 @@ export { IOTelAttributes, OTelAttributeValue, ExtendedOTelAttributeValue } from export { OTelException, IOTelExceptionWithCode, IOTelExceptionWithMessage, IOTelExceptionWithName } from "./interfaces/IException"; export { IOTelHrTime, OTelTimeInput } from "./interfaces/IOTelHrTime"; export { createOTelApi } from "./otel/api/OTelApi"; -export { OTelSdk } from "./otel/sdk/OTelSdk"; // OpenTelemetry Trace Interfaces export { ITraceApi } from "./interfaces/otel/trace/IOTelTraceApi"; @@ -194,8 +193,8 @@ export { IOTelErrorHandlers } from "./interfaces/otel/config/IOTelErrorHandlers" export { ITraceCfg } from "./interfaces/otel/config/IOTelTraceCfg"; // OpenTelemetry SDK Interfaces -export { IOTelSdk } from "./interfaces/otel/IOTelSdk"; -export { IOTelSdkCtx } from "./interfaces/otel/IOTelSdkCtx"; +export { IOTelWebSdk } from "./interfaces/otel/IOTelWebSdk"; +export { IOTelWebSdkConfig } from "./interfaces/otel/config/IOTelWebSdkConfig"; // OpenTelemetry Context export { createContextManager } from "./otel/api/context/contextManager"; @@ -262,6 +261,9 @@ export { createLogger } from "./otel/sdk/OTelLogger"; export { createMultiLogRecordProcessor } from "./otel/sdk/OTelMultiLogRecordProcessor"; export { loadDefaultConfig, reconfigureLimits } from "./otel/sdk/config"; +// SDK Entry Point +export { createOTelWebSdk } from "./otel/sdk/OTelWebSdk"; + // ======================================== // Application Insights Common Exports // ======================================== diff --git a/shared/otel-core/src/interfaces/otel/IOTelSdk.ts b/shared/otel-core/src/interfaces/otel/IOTelSdk.ts deleted file mode 100644 index 3f89550dd..000000000 --- a/shared/otel-core/src/interfaces/otel/IOTelSdk.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { IOTelApi } from "./IOTelApi"; -import { IOTelConfig } from "./config/IOTelConfig"; -import { IOTelTracerProvider } from "./trace/IOTelTracerProvider"; - -export interface IOTelSdk extends IOTelTracerProvider { - cfg: IOTelConfig; - - api: IOTelApi -} diff --git a/shared/otel-core/src/interfaces/otel/IOTelSdkCtx.ts b/shared/otel-core/src/interfaces/otel/IOTelSdkCtx.ts deleted file mode 100644 index 21c3733a9..000000000 --- a/shared/otel-core/src/interfaces/otel/IOTelSdkCtx.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { IOTelContext } from "./context/IOTelContext"; -import { IOTelSpanOptions } from "./trace/IOTelSpanOptions"; -import { IOTelTracer } from "./trace/IOTelTracer"; -import { IOTelTracerOptions } from "./trace/IOTelTracerOptions"; -import { IReadableSpan } from "./trace/IReadableSpan"; - -/** - * The context for the current IOTelSdk instance and it's configuration - */ -export interface IOTelSdkCtx { - /** - * The current {@link IOTelApi} instance that is being used. - */ - //api: IOTelApi; - - /** - * The current {@link IOTelContext} for the current IOTelSdk instance - */ - context: IOTelContext; - - // ------------------------------------------------- - // Trace Support - // ------------------------------------------------- - - /** - * Returns a Tracer, creating one if one with the given name and version is - * not already created. This may return - * - The same Tracer instance if one has already been created with the same name and version - * - A new Tracer instance if one has not already been created with the same name and version - * - A non-operational Tracer if the provider is not operational - * - * @param name - The name of the tracer or instrumentation library. - * @param version - The version of the tracer or instrumentation library. - * @param options - The options of the tracer or instrumentation library. - * @returns Tracer A Tracer with the given name and version - */ - getTracer(name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer; - - /** - * Starts a new {@link IOTelSpan}. Start the span without setting it on context. - * - * This method do NOT modify the current Context. - * - * @param name - The name of the span - * @param options - SpanOptions used for span creation - * @param context - Context to use to extract parent - * @returns Span The newly created span - * @example - * const span = tracer.startSpan('op'); - * span.setAttribute('key', 'value'); - * span.end(); - */ - startSpan: (name: string, options?: IOTelSpanOptions, context?: IOTelContext) => IReadableSpan; -} diff --git a/shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts b/shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts new file mode 100644 index 000000000..86441a903 --- /dev/null +++ b/shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IPromise } from "@nevware21/ts-async"; +import { IOTelWebSdkConfig } from "./config/IOTelWebSdkConfig"; +import { IOTelLogger } from "./logs/IOTelLogger"; +import { IOTelLoggerOptions } from "./logs/IOTelLoggerOptions"; +import { IOTelTracer } from "./trace/IOTelTracer"; +import { IOTelTracerOptions } from "./trace/IOTelTracerOptions"; + +/** + * Main interface for the OpenTelemetry Web SDK. + * Provides access to tracer and logger providers, configuration management, + * and complete lifecycle control including unload/cleanup. + * + * @remarks + * - Supports multiple isolated instances without global state + * - All dependencies injected through {@link IOTelWebSdkConfig} + * - Complete unload support — every instance must fully clean up on unload + * + * @example + * ```typescript + * const sdk = createOTelWebSdk({ + * resource: myResource, + * errorHandlers: myHandlers, + * contextManager: myContextManager, + * idGenerator: myIdGenerator, + * sampler: myAlwaysOnSampler, + * performanceNow: () => performance.now() + * }); + * + * // Get a tracer and create spans + * const tracer = sdk.getTracer("my-service"); + * const span = tracer.startSpan("operation"); + * span.end(); + * + * // Get a logger and emit log records + * const logger = sdk.getLogger("my-service"); + * logger.emit({ body: "Hello, World!" }); + * + * // Cleanup when done + * sdk.shutdown(); + * ``` + * + * @since 4.0.0 + */ +export interface IOTelWebSdk { + /** + * Returns a Tracer for creating spans. + * Tracers are cached by name + version combination — requesting the same + * name and version returns the same Tracer instance. + * + * @param name - The name of the tracer or instrumentation library + * @param version - The version of the tracer or instrumentation library + * @param options - Additional tracer options (e.g., schemaUrl) + * @returns A Tracer with the given name and version + * + * @example + * ```typescript + * const tracer = sdk.getTracer("my-component", "1.0.0"); + * const span = tracer.startSpan("my-operation"); + * ``` + */ + getTracer(name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer; + + /** + * Returns a Logger for emitting log records. + * Loggers are cached by name + version + schemaUrl combination — + * requesting the same combination returns the same Logger instance. + * + * @param name - The name of the logger or instrumentation library + * @param version - The version of the logger or instrumentation library + * @param options - Additional logger options (e.g., schemaUrl, scopeAttributes) + * @returns A Logger with the given name and version + * + * @example + * ```typescript + * const logger = sdk.getLogger("my-component", "1.0.0"); + * logger.emit({ body: "Operation completed", severityText: "INFO" }); + * ``` + */ + getLogger(name: string, version?: string, options?: IOTelLoggerOptions): IOTelLogger; + + // TODO: Phase 5 - Uncomment when metrics are implemented + // /** + // * Returns a Meter for recording metrics. + // * @param name - The name of the meter or instrumentation library + // * @param version - The version of the meter or instrumentation library + // * @param options - Additional meter options + // * @returns A Meter with the given name and version + // */ + // getMeter(name: string, version?: string, options?: IOTelMeterOptions): IOTelMeter; + + /** + * Forces all providers to flush any buffered data. + * This is useful before application shutdown to ensure all telemetry + * is exported. + * + * @returns A promise that resolves when the flush is complete + */ + forceFlush(): IPromise; + + /** + * Shuts down the SDK and releases all resources. + * After shutdown, the SDK instance is no longer usable — all + * subsequent calls to `getTracer` or `getLogger` will return + * no-op implementations. + * + * @remarks + * Shutdown performs the following: + * - Flushes all pending telemetry + * - Shuts down all providers (trace, log) + * - Removes all config change listeners (calls `IUnloadHook.rm()`) + * - Clears all cached instances + * + * @returns A promise that resolves when shutdown is complete + */ + shutdown(): IPromise; + + /** + * Gets the current SDK configuration (read-only snapshot). + * + * @returns The current SDK configuration + */ + getConfig(): Readonly; +} diff --git a/shared/otel-core/src/interfaces/otel/config/IOTelWebSdkConfig.ts b/shared/otel-core/src/interfaces/otel/config/IOTelWebSdkConfig.ts new file mode 100644 index 000000000..8dd264171 --- /dev/null +++ b/shared/otel-core/src/interfaces/otel/config/IOTelWebSdkConfig.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IOTelContextManager } from "../context/IOTelContextManager"; +import { IOTelLogRecordProcessor } from "../logs/IOTelLogRecordProcessor"; +import { IOTelResource } from "../resources/IOTelResource"; +import { IOTelIdGenerator } from "../trace/IOTelIdGenerator"; +import { IOTelSampler } from "../trace/IOTelSampler"; +import { IOTelErrorHandlers } from "./IOTelErrorHandlers"; + +/** + * Configuration interface for the OpenTelemetry Web SDK. + * Provides all configuration options required for SDK initialization. + * + * + * @remarks + * - All properties must be provided during SDK creation + * - Local caching of config values uses `onConfigChange` callbacks + * - Supports dynamic configuration — config values can change at runtime + * + * @example + * ```typescript + * const config: IOTelWebSdkConfig = { + * resource: myResource, + * errorHandlers: myErrorHandlers, + * contextManager: myContextManager, + * idGenerator: myIdGenerator, + * sampler: myAlwaysOnSampler, + * logProcessors: [myLogProcessor], + * performanceNow: () => performance.now() + * }; + * + * const sdk = createOTelWebSdk(config); + * ``` + * + * @since 4.0.0 + */ +export interface IOTelWebSdkConfig { + /** + * Resource information for telemetry source identification. + * Provides attributes that describe the entity producing telemetry, + * such as service name, version, and environment. + * + * @remarks + * The resource is shared across all providers (trace, log, metrics) + * within this SDK instance. + */ + resource: IOTelResource; + + /** + * Error handlers for SDK internal diagnostics. + * Provides hooks to customize how different types of errors and + * diagnostic messages are handled within the SDK. + * + * @remarks + * Error handlers are propagated to all sub-components created by the SDK. + * If individual handler callbacks are not provided, default behavior + * (console logging) is used. + * + * @see {@link IOTelErrorHandlers} + */ + errorHandlers: IOTelErrorHandlers; + + /** + * Context manager implementation. + * Manages the propagation of context (including active spans) across + * asynchronous operations. + * + * @see {@link IOTelContextManager} + */ + contextManager: IOTelContextManager; + + /** + * ID generator for span and trace IDs. + * Generates unique identifiers for distributed tracing. + * + * @see {@link IOTelIdGenerator} + */ + idGenerator: IOTelIdGenerator; + + /** + * Sampler implementation. + * Determines which traces/spans should be recorded and exported. + * + * @see {@link IOTelSampler} + */ + sampler: IOTelSampler; + + /** + * Performance timing function. + * Injected for testability — allows tests to control time measurement. + * + * @returns The current high-resolution time in milliseconds + * + * @example + * ```typescript + * // Production usage + * performanceNow: () => performance.now() + * + * // Test usage with fake timers + * performanceNow: () => fakeTimer.now() + * ``` + */ + performanceNow: () => number; + + /** + * Log record processors for the log pipeline. + * Each processor receives log records and can transform, filter, + * or export them. + * + * @remarks + * Processors are invoked in order. If not provided, defaults to + * an empty array (no log processing). + * + * @see {@link IOTelLogRecordProcessor} + */ + logProcessors?: IOTelLogRecordProcessor[]; + + // TODO: Phase 2 - Uncomment when IOTelSpanProcessor is implemented + // /** + // * Span processors for the trace pipeline. + // * Each processor receives spans and can transform, filter, + // * or export them. + // * + // * @see IOTelSpanProcessor + // */ + // spanProcessors?: IOTelSpanProcessor[]; + + // TODO: Phase 5 - Uncomment when IOTelMetricReader is implemented + // /** + // * Metric readers for the metric pipeline. + // * + // * @see IOTelMetricReader + // */ + // metricReaders?: IOTelMetricReader[]; +} diff --git a/shared/otel-core/src/interfaces/otel/trace/IOTelTracerCtx.ts b/shared/otel-core/src/interfaces/otel/trace/IOTelTracerCtx.ts index 191411d7b..0b8ac0b1a 100644 --- a/shared/otel-core/src/interfaces/otel/trace/IOTelTracerCtx.ts +++ b/shared/otel-core/src/interfaces/otel/trace/IOTelTracerCtx.ts @@ -8,7 +8,7 @@ import { IOTelSpanOptions } from "./IOTelSpanOptions"; import { IReadableSpan } from "./IReadableSpan"; /** - * The context for the current IOTelSdk instance and it's configuration + * The context for a tracer instance and its configuration * @since 3.4.0 */ export interface IOTelTracerCtx { diff --git a/shared/otel-core/src/otel/api/context/context.ts b/shared/otel-core/src/otel/api/context/context.ts index 7531e8106..6f2b1754c 100644 --- a/shared/otel-core/src/otel/api/context/context.ts +++ b/shared/otel-core/src/otel/api/context/context.ts @@ -54,9 +54,9 @@ export function createContext(otelApi: IOTelApi, parent?: IOTelContext): IOTelCo } function _setValue(key: symbol, value: unknown) { - let newContext = createContext(theContext.api, parent); + let newContext = createContext(theContext.api, theContext); ((newContext as any)[_InternalContextKey.v])[key] = value; - return theContext; + return newContext; } function _deleteValue(key: symbol) { diff --git a/shared/otel-core/src/otel/sdk/OTelSdk.ts b/shared/otel-core/src/otel/sdk/OTelSdk.ts deleted file mode 100644 index 2a516e536..000000000 --- a/shared/otel-core/src/otel/sdk/OTelSdk.ts +++ /dev/null @@ -1,263 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import dynamicProto from "@microsoft/dynamicproto-js"; -import { IAppInsightsCore } from "../../interfaces/ai/IAppInsightsCore"; -import { IConfiguration } from "../../interfaces/ai/IConfiguration"; -import { IProcessTelemetryContext } from "../../interfaces/ai/IProcessTelemetryContext"; -import { ITelemetryItem } from "../../interfaces/ai/ITelemetryItem"; -import { IPlugin } from "../../interfaces/ai/ITelemetryPlugin"; -import { ITelemetryPluginChain } from "../../interfaces/ai/ITelemetryPluginChain"; -import { IOTelApi } from "../../interfaces/otel/IOTelApi"; -import { IOTelSdk } from "../../interfaces/otel/IOTelSdk"; -import { IOTelConfig } from "../../interfaces/otel/config/IOTelConfig"; -import { IOTelTracer } from "../../interfaces/otel/trace/IOTelTracer"; -import { IOTelTracerOptions } from "../../interfaces/otel/trace/IOTelTracerOptions"; - -// interface TraceList { -// name: string; -// tracer: IOTelTracer; -// version?: string; -// schemaUrl?: string; -// } - -// TODO: Enable -// function _createSpanContext(parentSpanContext: SpanContext | null, idGenerator: IOTelIdGenerator): SpanContext { -// const spanId = idGenerator.generateSpanId(); -// let traceId: string; -// let traceState: TraceState; -// if (!parentSpanContext || isSpanContextValid(parentSpanContext)) { -// // if parentSpanContext is not valid, generate a new one -// traceId = idGenerator.generateTraceId(); -// } else { -// traceId = parentSpanContext.traceId; -// traceState = parentSpanContext.traceState; -// } - -// let traceFlags = parentSpanContext ? parentSpanContext.traceFlags : eW3CTraceFlags.None; - -// return { -// traceId: traceId, -// spanId: spanId, -// traceFlags: traceFlags, -// traceState: traceState, -// isRemote: false -// }; -// } - -// function _isSampledOut(sampler: IOTelSampler, context: Context, spanContext: SpanContext, kind: SpanKind, attributes: Attributes, links: Link[]): boolean { -// if (sampler) { -// const samplingResult = sampler.shouldSample(context, spanContext.traceId, spanContext.spanId, kind, attributes, links); -// spanContext.traceState = samplingResult.traceState || spanContext.traceState; -// if (samplingResult.decision === eOTelSamplingDecision.NOT_RECORD) { -// return true; -// } -// } - -// return false; -// } - -export class OTelSdk implements IOTelSdk { - public static identifier: string = "OTelSdk"; - - public identifier: string = OTelSdk.identifier; - public cfg: IOTelConfig; - public api: IOTelApi; - - constructor() { - // NOTE!: DON'T set default values here, instead set them in the _initDefaults() function as it is also called during teardown() - // let _configHandler: IDynamicConfigHandler; - // let _otelApi: ILazyValue; - // let _tracers: { [key: string]: TraceList[] }; - - dynamicProto(OTelSdk, this, (_self, _base) => { - // Set the default values (also called during teardown) - _initDefaults(); - - // objDefineProps(_self, { - // cfg: { g: () => _configHandler.cfg }, - // api: { g: () => _otelApi.v } - // }); - - // Creating the self.initialize = () - _self.initialize = (config: IConfiguration, core: IAppInsightsCore, extensions: IPlugin[], pluginChain?: ITelemetryPluginChain): void => { - // TODO: Enable - // if (!_self.isInitialized()) { - // _base.initialize(config, core, extensions, pluginChain); - - // _populateDefaults(config); - // } - }; - - // TODO: Enable - //_self.getTracer = _getTracer; - - - // function _getTracer(name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer { - // let tracer: IOTelTracer; - - - // let tracerVer = version || STR_EMPTY; - // let tracerSchema = options ? options.schemaUrl : null; - // let keyName = normalizeJsName(name + "@" + tracerVer); - // let tracerList = _tracers?.[keyName]; - - // if (tracerList) { - // arrForEach(tracerList, (item) => { - // if (item.name == name && item.version == tracerVer && item.schemaUrl == tracerSchema) { - // tracer = item.tracer; - // return -1; - // } - // }); - // } else { - // // Ensure _tracers is initialized before accessing it - // if (!_tracers) { - // _tracers = {}; - // } - // tracerList = _tracers[keyName] = []; - // } - - // if (!tracer) { - // Ensure otelApi is available before accessing its properties - - //let otelApi = _otelApi.v; - // let tracerCtx: IOTelTracerCtx = { - // ctxMgr: otelApi?.context, - // //context: _otelSdkCtx.v.context, - // startSpan: _startSpan - // }; - - // tracer = createTracer(tracerCtx, { - // name, - // version, - // schemaUrl: options ? options.schemaUrl : null - // }); - - // // tracerList is guaranteed to be defined by the logic above - // tracerList.push({ name, version: tracerVer, schemaUrl: tracerSchema, tracer }); - // } - - // return tracer; - // } - - // function _startSpan(name: string, options?: SpanOptions, pContext?: Context): IOTelSpan | IReadableSpan { - // let spanOpts = options || {}; - // let kind = spanOpts.kind || SpanKind.INTERNAL; - // let otelApi = _otelApi.v; - // let theContext = pContext || otelApi?.context?.active(); - // let parentSpanContext: SpanContext | null = null; - - // if (spanOpts.root) { - // theContext = deleteContextSpan(theContext); - // } - - // const parentSpan = getContextSpan(theContext); - - // // if Tracing suppressed - // if (!isTracingSuppressed(theContext)) { - // let traceCfg = _configHandler.cfg.traceCfg; - // let idGenerator = traceCfg.idGenerator; - - // parentSpanContext = parentSpan && parentSpan.spanContext(); - // let spanContext = _createSpanContext(parentSpanContext, idGenerator); - // let attributes = spanOpts.attributes || {}; - // let links = spanOpts.links || []; - - // const sampler = traceCfg.sampler; - // if (!_isSampledOut(sampler, theContext, spanContext, kind, attributes, links)) { - - // let spanCtx: IOTelSpanCtx = { - // api: otelApi, - // resource: null, - // instrumentationScope: null, - // context: theContext, - // spanContext: spanContext, - // attributes: attributes, - // links: links, - // isRecording: true, - // startTime: spanOpts.startTime, - // parentSpanContext: parentSpanContext, - // onEnd: (span: IReadableSpan) => { - // _endSpan(this, span); - // } - // }; - - // return createSpan(spanCtx, name, kind); - // } - // } - - // return wrapSpanContext(parentSpanContext); - // } - - // function _endSpan(spanCtx: IOTelSpanCtx, span: IOTelSpan): void { - // if ((span.spanContext().traceFlags & eW3CTraceFlags.Sampled) === 0) { - // return; - // } - // // _self.core.trackTrace({ - // // message: span.name, - // // properties: span.attributes as Attributes - // // }); - // } - - function _initDefaults() { - // Use a default logger so initialization errors are not dropped on the floor with full logging - // TODO: Enable - //_configHandler = createDynamicConfig({} as IOTelConfig, traceApiDefaultConfigValues as any, _self.diagLog()); - // let otelConfig = _configHandler.cfg; - // _tracers = {}; - - - // _otelApi = createDeferredCachedValue(() => { - // let otelApiCtx: IOTelApiCtx = { - // otelCfg: null, - // traceProvider: { - // getTracer: _getTracer - // }, - // diagLogger: _self.diagLog() - // }; - - // // make the config lookup dynamic, so when the config changes we return the current - // objDefine(otelApiCtx, "otelCfg", { g: () => otelConfig }); - - // return createOTelApi(otelApiCtx) - // }); - } - - // function _populateDefaults(config: IConfiguration) { - // _self._addHook(onConfigChange(config, (details) => { - // let config = details.cfg; - // let ctx = createProcessTelemetryContext(null, config, _self.core); - // let _otelConfig = ctx.getExtCfg(OTelSdk.identifier, traceApiDefaultConfigValues, true); - // })); - // } - - }); - } - - public initialize(config: IConfiguration, core: IAppInsightsCore, extensions: IPlugin[], pluginChain?: ITelemetryPluginChain) { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - } - - public processTelemetry(item: ITelemetryItem, itemCtx?: IProcessTelemetryContext) { - // TODO: Enable - //this.processNext(item, itemCtx); - } - - /** - * Returns a Tracer, creating one if one with the given name and version is - * not already created. This may return - * - The same Tracer instance if one has already been created with the same name and version - * - A new Tracer instance if one has not already been created with the same name and version - * - A non-operational Tracer if the provider is not operational - * - * @param name - The name of the tracer or instrumentation library. - * @param version - The version of the tracer or instrumentation library. - * @param options - The options of the tracer or instrumentation library. - * @returns A Tracer with the given name and version - */ - public getTracer(name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return null; - } - -} diff --git a/shared/otel-core/src/otel/sdk/OTelWebSdk.ts b/shared/otel-core/src/otel/sdk/OTelWebSdk.ts new file mode 100644 index 000000000..9d3a69a2e --- /dev/null +++ b/shared/otel-core/src/otel/sdk/OTelWebSdk.ts @@ -0,0 +1,446 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IPromise, createAllPromise, createSyncPromise } from "@nevware21/ts-async"; +import { isFunction, objDefine, objForEachKey } from "@nevware21/ts-utils"; +import { createDynamicConfig, onConfigChange } from "../../config/DynamicConfig"; +import { createDistributedTraceContext } from "../../core/TelemetryHelpers"; +import { eW3CTraceFlags } from "../../enums/W3CTraceFlags"; +import { eOTelSamplingDecision } from "../../enums/otel/OTelSamplingDecision"; +import { OTelSpanKind, eOTelSpanKind } from "../../enums/otel/OTelSpanKind"; +import { IDistributedTraceContext } from "../../interfaces/ai/IDistributedTraceContext"; +import { IUnloadHook } from "../../interfaces/ai/IUnloadHook"; +import { IOTelApi } from "../../interfaces/otel/IOTelApi"; +import { IOTelWebSdk } from "../../interfaces/otel/IOTelWebSdk"; +import { IOTelConfig } from "../../interfaces/otel/config/IOTelConfig"; +import { IOTelErrorHandlers } from "../../interfaces/otel/config/IOTelErrorHandlers"; +import { IOTelWebSdkConfig } from "../../interfaces/otel/config/IOTelWebSdkConfig"; +import { IOTelContext } from "../../interfaces/otel/context/IOTelContext"; +import { IOTelLogRecord } from "../../interfaces/otel/logs/IOTelLogRecord"; +import { IOTelLogger } from "../../interfaces/otel/logs/IOTelLogger"; +import { IOTelLoggerOptions } from "../../interfaces/otel/logs/IOTelLoggerOptions"; +import { IOTelSpanCtx } from "../../interfaces/otel/trace/IOTelSpanCtx"; +import { IOTelSpanOptions } from "../../interfaces/otel/trace/IOTelSpanOptions"; +import { IOTelTracer } from "../../interfaces/otel/trace/IOTelTracer"; +import { IOTelTracerOptions } from "../../interfaces/otel/trace/IOTelTracerOptions"; +import { IReadableSpan } from "../../interfaces/otel/trace/IReadableSpan"; +import { handleError, handleWarn } from "../../internal/handleErrors"; +import { setProtoTypeName } from "../../utils/HelperFuncs"; +import { createContext } from "../api/context/context"; +import { createSpan } from "../api/trace/span"; +import { getContextSpan, setContextSpan } from "../api/trace/utils"; +import { createLoggerProvider } from "./OTelLoggerProvider"; + +/** + * Creates a no-op logger that silently discards all emitted log records. + * Used when the SDK has been shut down. + * @returns A no-op IOTelLogger instance + */ +function _createNoopLogger(): IOTelLogger { + return { + emit(_logRecord: IOTelLogRecord): void { + // noop - SDK is shut down + } + }; +} + +/** + * Creates an OpenTelemetry Web SDK instance. + * This is the main entry point factory for the SDK. + * + * The SDK coordinates trace and log providers, manages their lifecycle, + * and ensures complete cleanup on shutdown. + * + * @param config - The SDK configuration with all required dependencies injected + * @returns An initialized IOTelWebSdk instance + * + * @remarks + * - All dependencies must be injected through config — no global state + * - Multiple SDK instances can coexist without interference + * - Config is used directly — never copied with spread operator + * - Local config caching uses `onConfigChange` callbacks + * - Complete unload support — call `shutdown()` to release all resources + * + * @example + * ```typescript + * import { createOTelWebSdk } from "@microsoft/applicationinsights-otelwebsdk-js"; + * + * const sdk = createOTelWebSdk({ + * resource: myResource, + * errorHandlers: { warn: (msg) => console.warn(msg) }, + * contextManager: myContextManager, + * idGenerator: myIdGenerator, + * sampler: myAlwaysOnSampler, + * performanceNow: () => performance.now(), + * logProcessors: [myLogProcessor] + * }); + * + * // Use the SDK + * const tracer = sdk.getTracer("my-service", "1.0.0"); + * const logger = sdk.getLogger("my-service", "1.0.0"); + * + * // Clean up when done + * sdk.shutdown(); + * ``` + * + * @since 3.4.0 + */ +export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { + // Validate required dependencies upfront + let _handlers: IOTelErrorHandlers = config.errorHandlers; + + if (!config.resource) { + handleError(_handlers, "createOTelWebSdk: resource must be provided"); + } + if (!config.errorHandlers) { + // Use an empty handlers object as fallback so handleError/handleWarn don't fail + _handlers = {}; + handleWarn(_handlers, "createOTelWebSdk: errorHandlers should be provided"); + } + if (!config.contextManager) { + handleError(_handlers, "createOTelWebSdk: contextManager must be provided"); + } + if (!config.idGenerator) { + handleError(_handlers, "createOTelWebSdk: idGenerator must be provided"); + } + if (!config.sampler) { + handleError(_handlers, "createOTelWebSdk: sampler must be provided"); + } + if (!config.performanceNow) { + handleError(_handlers, "createOTelWebSdk: performanceNow must be provided"); + } + + // Private closure state + let _isShutdown = false; + let _tracers: { [key: string]: IOTelTracer } = {}; + let _unloadHooks: IUnloadHook[] = []; + + // Make the config dynamic so we can watch for changes, then cache values + let _sdkConfig = createDynamicConfig(config).cfg; + let _resource = _sdkConfig.resource; + let _contextManager = _sdkConfig.contextManager; + let _idGenerator = _sdkConfig.idGenerator; + let _sampler = _sdkConfig.sampler; + + // Create a minimal IOTelApi adapter that bridges the SDK config to what createSpan needs. + // createSpan() reads api.cfg.errorHandlers and api.cfg.traceCfg — we provide those from + // the SDK config. The host and trace properties are not used by createSpan. + let _otelCfg: IOTelConfig = { + errorHandlers: _handlers + }; + let _apiAdapter = { cfg: _otelCfg } as IOTelApi; + + // Watch for config changes and update cached values + api adapter + let _configUnload = onConfigChange(_sdkConfig, function () { + _resource = _sdkConfig.resource; + _contextManager = _sdkConfig.contextManager; + _idGenerator = _sdkConfig.idGenerator; + _sampler = _sdkConfig.sampler; + _handlers = _sdkConfig.errorHandlers || _handlers; + _otelCfg.errorHandlers = _handlers; + }); + _unloadHooks.push(_configUnload); + + // Create a root context for the SDK so that context operations always have a valid base + let _rootContext: IOTelContext = createContext(_apiAdapter); + + // Create the logger provider using existing factory + let _loggerProvider = createLoggerProvider({ + resource: _resource, + processors: _sdkConfig.logProcessors || [] + }); + + /** + * Returns the current active context from the context manager, falling back + * to the SDK root context if none is active. + * @returns The active IOTelContext + */ + function _getActiveContext(): IOTelContext { + return _contextManager.active() || _rootContext; + } + + // Build the SDK instance using closure pattern + let _self: IOTelWebSdk = {} as IOTelWebSdk; + + _self.getTracer = function (name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer { + if (_isShutdown) { + handleWarn(_handlers, "A shutdown OTelWebSdk cannot provide a Tracer"); + // Return a no-op tracer + return _createNoopTracer(); + } + + let tracerName = name || "unknown"; + let tracerVersion = version || ""; + let schemaUrl = options ? options.schemaUrl || "" : ""; + let key = tracerName + "@" + tracerVersion + ":" + schemaUrl; + + if (!_tracers[key]) { + _tracers[key] = _createSdkTracer(tracerName, tracerVersion); + } + + return _tracers[key]; + }; + + _self.getLogger = function (name: string, version?: string, options?: IOTelLoggerOptions): IOTelLogger { + if (_isShutdown) { + handleWarn(_handlers, "A shutdown OTelWebSdk cannot provide a Logger"); + return _createNoopLogger(); + } + + return _loggerProvider.getLogger(name, version, options); + }; + + _self.forceFlush = function (): IPromise { + if (_isShutdown) { + handleWarn(_handlers, "Cannot force flush a shutdown OTelWebSdk"); + return createSyncPromise(function (resolve) { + resolve(); + }); + } + + let operations: IPromise[] = []; + + // Flush the logger provider + if (_loggerProvider.forceFlush) { + let result = _loggerProvider.forceFlush(); + if (result) { + operations.push(result); + } + } + + // TODO: Phase 2 - Flush span processors when available + + if (operations.length > 0) { + return createAllPromise(operations).then(function (): void { + // All flushed + }); + } + + return createSyncPromise(function (resolve) { + resolve(); + }); + }; + + _self.shutdown = function (): IPromise { + if (_isShutdown) { + handleWarn(_handlers, "shutdown may only be called once per OTelWebSdk"); + return createSyncPromise(function (resolve) { + resolve(); + }); + } + + _isShutdown = true; + + let operations: IPromise[] = []; + + // Shutdown the logger provider + if (_loggerProvider.shutdown) { + let result = _loggerProvider.shutdown(); + if (result) { + operations.push(result); + } + } + + // TODO: Phase 2 - Shutdown span processors when available + + // Remove all config change listeners + for (let i = 0; i < _unloadHooks.length; i++) { + _unloadHooks[i].rm(); + } + _unloadHooks = []; + + // Clear cached tracers + _tracers = {}; + + if (operations.length > 0) { + return createAllPromise(operations).then(function (): void { + // All shut down + }); + } + + return createSyncPromise(function (resolve) { + resolve(); + }); + }; + + _self.getConfig = function (): Readonly { + return _sdkConfig; + }; + + /** + * Creates a tracer instance for this SDK. + * The tracer creates spans using the SDK's context manager, ID generator, and sampler. + * Follows the OpenTelemetry Tracer specification for span creation and context management. + * + * @param tracerName - The name of the tracer (instrumentation library) + * @param tracerVersion - The version of the tracer (instrumentation library) + * @returns An IOTelTracer instance that creates functional spans + */ + function _createSdkTracer(tracerName: string, tracerVersion: string): IOTelTracer { + + /** + * Starts a new span without setting it on the current context. + * Handles ID generation, sampling, and parent span propagation. + * + * @param spanName - The name of the span + * @param options - Optional span creation options (kind, attributes, links, startTime, root) + * @param context - Optional context to extract parent span from; defaults to active context + * @returns A new IReadableSpan, or null if the SDK is shutdown + */ + function _startSpan(spanName: string, options?: IOTelSpanOptions, context?: IOTelContext): IReadableSpan | null { + if (_isShutdown) { + return null; + } + + let opts = options || {}; + let kind: OTelSpanKind = opts.kind || eOTelSpanKind.INTERNAL; + let activeCtx = context || _getActiveContext(); + let parentSpanCtx: IDistributedTraceContext = null; + let newCtx: IDistributedTraceContext; + + // Determine parent span context unless root span is requested + if (!opts.root && activeCtx) { + let parentSpan = getContextSpan(activeCtx); + if (parentSpan) { + parentSpanCtx = parentSpan.spanContext(); + } + } + + // Create the new span's distributed trace context + if (parentSpanCtx) { + // Child span — inherits traceId from parent + newCtx = createDistributedTraceContext(parentSpanCtx); + } else { + // Root span — new trace + newCtx = createDistributedTraceContext(); + newCtx.traceId = _idGenerator.generateTraceId(); + } + + // Always generate a new spanId for this span + newCtx.spanId = _idGenerator.generateSpanId(); + + // Run the sampler to decide whether to record this span + let attributes = opts.attributes || {}; + let links = opts.links || []; + let samplingResult = _sampler.shouldSample( + activeCtx, newCtx.traceId, spanName, kind, attributes, links + ); + + // Determine recording and sampled flags from the sampling decision + let isRecording = samplingResult.decision !== eOTelSamplingDecision.NOT_RECORD; + let isSampled = samplingResult.decision === eOTelSamplingDecision.RECORD_AND_SAMPLED; + + // Set trace flags based on sampling decision + newCtx.traceFlags = isSampled ? eW3CTraceFlags.Sampled : eW3CTraceFlags.None; + + // Apply trace state from sampler if provided + if (samplingResult.traceState) { + // The sampler may have provided an updated trace state + // Note: createDistributedTraceContext handles trace state internally + // For now we rely on the context's built-in trace state management + } + + // Merge sampler-provided attributes with user-provided attributes + let spanAttributes = attributes; + if (isRecording && samplingResult.attributes) { + // Merge: user attributes take precedence, sampler attributes fill gaps + spanAttributes = {}; + let samplerAttrs = samplingResult.attributes; + objForEachKey(samplerAttrs, function (key, value) { + spanAttributes[key] = value; + }); + objForEachKey(attributes, function (key, value) { + spanAttributes[key] = value; + }); + } + + // Build the span context for createSpan + let spanCtx: IOTelSpanCtx = { + api: _apiAdapter, + resource: _resource, + instrumentationScope: { name: tracerName, version: tracerVersion }, + spanContext: newCtx, + attributes: spanAttributes, + startTime: opts.startTime, + isRecording: isRecording + // TODO: Phase 2 - Add onEnd callback for span processor notification + }; + + // Set parent span context as a non-writable property if parent exists + if (parentSpanCtx) { + objDefine(spanCtx, "parentSpanContext", { + v: parentSpanCtx, + w: false + }); + } + + return createSpan(spanCtx, spanName, kind); + } + + let tracer: IOTelTracer = setProtoTypeName({ + startSpan: function (spanName: string, options?: IOTelSpanOptions, context?: IOTelContext): IReadableSpan | null { + return _startSpan(spanName, options, context); + }, + startActiveSpan: function unknown>( + spanNameArg: string, + optionsOrFn?: IOTelSpanOptions | F, + fnOrContext?: F | IOTelContext, + maybeFn?: F + ): ReturnType { + // Resolve overloaded parameters: + // Overload 1: startActiveSpan(name, fn) + // Overload 2: startActiveSpan(name, options, fn) + // Overload 3: startActiveSpan(name, options, context, fn) + let opts: IOTelSpanOptions = null; + let fn: F = null; + let ctx: IOTelContext = null; + + if (isFunction(optionsOrFn)) { + // Overload 1: (name, fn) + fn = optionsOrFn as F; + } else if (isFunction(fnOrContext)) { + // Overload 2: (name, options, fn) + opts = optionsOrFn as IOTelSpanOptions; + fn = fnOrContext as F; + } else { + // Overload 3: (name, options, context, fn) + opts = optionsOrFn as IOTelSpanOptions; + ctx = fnOrContext as IOTelContext; + fn = maybeFn; + } + + // Create the span using the resolved parameters + let span = _startSpan(spanNameArg, opts, ctx); + + // Set the span as active in a new context and execute the callback + let activeCtx = ctx || _getActiveContext(); + let contextWithSpan = setContextSpan(activeCtx, span); + + return _contextManager.with(contextWithSpan, function () { + return fn(span); + }) as ReturnType; + } + }, "OTelTracer (" + tracerName + "@" + tracerVersion + ")"); + + return tracer; + } + + /** + * Creates a no-op tracer that does not create any spans. + * Used when the SDK has been shut down. + * + * @returns A no-op IOTelTracer instance + */ + function _createNoopTracer(): IOTelTracer { + return setProtoTypeName({ + startSpan: function (): IReadableSpan | null { + return null; + }, + startActiveSpan: function (): undefined { + return undefined; + } + }, "OTelNoopTracer"); + } + + return setProtoTypeName(_self, "OTelWebSdk"); +} diff --git a/shared/otel-core/src/utils/DataCacheHelper.ts b/shared/otel-core/src/utils/DataCacheHelper.ts index cd02749a2..237c33993 100644 --- a/shared/otel-core/src/utils/DataCacheHelper.ts +++ b/shared/otel-core/src/utils/DataCacheHelper.ts @@ -6,7 +6,7 @@ import { STR_EMPTY } from "../constants/InternalConstants"; import { normalizeJsName } from "./HelperFuncs"; import { newId } from "./RandomHelper"; -const version = "#version#"; +const version = '0.0.1-alpha'; let instanceName = "." + newId(6); let _dataUid = 0; From 4b2e813d68742c35a8bf311ade6107c3a8ed026b Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:37:42 -0800 Subject: [PATCH 2/4] Update shared/otel-core/src/utils/DataCacheHelper.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- shared/otel-core/src/utils/DataCacheHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/otel-core/src/utils/DataCacheHelper.ts b/shared/otel-core/src/utils/DataCacheHelper.ts index 237c33993..cd02749a2 100644 --- a/shared/otel-core/src/utils/DataCacheHelper.ts +++ b/shared/otel-core/src/utils/DataCacheHelper.ts @@ -6,7 +6,7 @@ import { STR_EMPTY } from "../constants/InternalConstants"; import { normalizeJsName } from "./HelperFuncs"; import { newId } from "./RandomHelper"; -const version = '0.0.1-alpha'; +const version = "#version#"; let instanceName = "." + newId(6); let _dataUid = 0; From 262b74193a4d973927b034133144b3d84cbdf6c0 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:38:01 -0800 Subject: [PATCH 3/4] Update shared/otel-core/src/otel/sdk/OTelWebSdk.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- shared/otel-core/src/otel/sdk/OTelWebSdk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/otel-core/src/otel/sdk/OTelWebSdk.ts b/shared/otel-core/src/otel/sdk/OTelWebSdk.ts index 9d3a69a2e..f7b2397a4 100644 --- a/shared/otel-core/src/otel/sdk/OTelWebSdk.ts +++ b/shared/otel-core/src/otel/sdk/OTelWebSdk.ts @@ -83,7 +83,7 @@ function _createNoopLogger(): IOTelLogger { * sdk.shutdown(); * ``` * - * @since 3.4.0 + * @since 4.0.0 */ export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { // Validate required dependencies upfront From 1e23bd4fda4a8f2e8556d8d18c948299fb6fb3a3 Mon Sep 17 00:00:00 2001 From: Hector Hernandez <39923391+hectorhdzg@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:38:37 -0700 Subject: [PATCH 4/4] Address comments --- .../src/interfaces/otel/IOTelWebSdk.ts | 3 +- shared/otel-core/src/otel/sdk/OTelWebSdk.ts | 70 ++++++++++++++++--- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts b/shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts index 86441a903..bb819a358 100644 --- a/shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts +++ b/shared/otel-core/src/interfaces/otel/IOTelWebSdk.ts @@ -118,7 +118,8 @@ export interface IOTelWebSdk { shutdown(): IPromise; /** - * Gets the current SDK configuration (read-only snapshot). + * Gets the current SDK configuration as a live reference. + * Callers should treat the returned configuration as read-only. * * @returns The current SDK configuration */ diff --git a/shared/otel-core/src/otel/sdk/OTelWebSdk.ts b/shared/otel-core/src/otel/sdk/OTelWebSdk.ts index f7b2397a4..46bfcd983 100644 --- a/shared/otel-core/src/otel/sdk/OTelWebSdk.ts +++ b/shared/otel-core/src/otel/sdk/OTelWebSdk.ts @@ -89,25 +89,33 @@ export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { // Validate required dependencies upfront let _handlers: IOTelErrorHandlers = config.errorHandlers; - if (!config.resource) { - handleError(_handlers, "createOTelWebSdk: resource must be provided"); - } if (!config.errorHandlers) { // Use an empty handlers object as fallback so handleError/handleWarn don't fail _handlers = {}; handleWarn(_handlers, "createOTelWebSdk: errorHandlers should be provided"); } + + // Validate all required dependencies and fail fast with a no-op SDK if any are missing + let _hasMissing = false; + if (!config.resource) { + handleError(_handlers, "createOTelWebSdk: resource must be provided"); + _hasMissing = true; + } if (!config.contextManager) { handleError(_handlers, "createOTelWebSdk: contextManager must be provided"); + _hasMissing = true; } if (!config.idGenerator) { handleError(_handlers, "createOTelWebSdk: idGenerator must be provided"); + _hasMissing = true; } if (!config.sampler) { handleError(_handlers, "createOTelWebSdk: sampler must be provided"); + _hasMissing = true; } - if (!config.performanceNow) { - handleError(_handlers, "createOTelWebSdk: performanceNow must be provided"); + + if (_hasMissing) { + return _createNoopSdk(config); } // Private closure state @@ -123,8 +131,9 @@ export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { let _sampler = _sdkConfig.sampler; // Create a minimal IOTelApi adapter that bridges the SDK config to what createSpan needs. - // createSpan() reads api.cfg.errorHandlers and api.cfg.traceCfg — we provide those from - // the SDK config. The host and trace properties are not used by createSpan. + // createSpan() reads api.cfg.errorHandlers (provided here) and api.cfg.traceCfg (optional, + // accessed with safe-navigation so undefined is fine). The host and trace properties are + // not used by createSpan. let _otelCfg: IOTelConfig = { errorHandlers: _handlers }; @@ -175,7 +184,7 @@ export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { let key = tracerName + "@" + tracerVersion + ":" + schemaUrl; if (!_tracers[key]) { - _tracers[key] = _createSdkTracer(tracerName, tracerVersion); + _tracers[key] = _createSdkTracer(tracerName, tracerVersion, schemaUrl); } return _tracers[key]; @@ -276,7 +285,7 @@ export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { * @param tracerVersion - The version of the tracer (instrumentation library) * @returns An IOTelTracer instance that creates functional spans */ - function _createSdkTracer(tracerName: string, tracerVersion: string): IOTelTracer { + function _createSdkTracer(tracerName: string, tracerVersion: string, schemaUrl: string): IOTelTracer { /** * Starts a new span without setting it on the current context. @@ -358,7 +367,7 @@ export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { let spanCtx: IOTelSpanCtx = { api: _apiAdapter, resource: _resource, - instrumentationScope: { name: tracerName, version: tracerVersion }, + instrumentationScope: { name: tracerName, version: tracerVersion, schemaUrl: schemaUrl || undefined }, spanContext: newCtx, attributes: spanAttributes, startTime: opts.startTime, @@ -444,3 +453,44 @@ export function createOTelWebSdk(config: IOTelWebSdkConfig): IOTelWebSdk { return setProtoTypeName(_self, "OTelWebSdk"); } + +/** + * Creates a no-op SDK instance that silently discards all operations. + * Returned when required dependencies are missing to prevent runtime crashes. + * @param config - The original config, used for getConfig() + * @returns A safe no-op IOTelWebSdk instance + */ +function _createNoopSdk(config: IOTelWebSdkConfig): IOTelWebSdk { + let _resolvedPromise = createSyncPromise(function (resolve: () => void) { + resolve(); + }); + + return setProtoTypeName({ + getTracer: function (): IOTelTracer { + return setProtoTypeName({ + startSpan: function (): IReadableSpan | null { + return null; + }, + startActiveSpan: function (): undefined { + return undefined; + } + }, "OTelNoopTracer"); + }, + getLogger: function (): IOTelLogger { + return { + emit: function (): void { + // noop + } + }; + }, + forceFlush: function (): IPromise { + return _resolvedPromise; + }, + shutdown: function (): IPromise { + return _resolvedPromise; + }, + getConfig: function (): Readonly { + return config; + } + }, "OTelNoopWebSdk"); +}