diff --git a/.aiAutoMinify.json b/.aiAutoMinify.json index e296db008..1835243d1 100644 --- a/.aiAutoMinify.json +++ b/.aiAutoMinify.json @@ -6,6 +6,7 @@ "ePendingOp", "CallbackType", "eTraceStateKeyType", + "eDependencyTypes", "eEventsDiscardedReason", "eBatchDiscardedReason", "FeatureOptInMode", @@ -19,7 +20,14 @@ "TelemetryUnloadReason", "TelemetryUpdateReason", "eTraceHeadersMode", - "eW3CTraceFlags" + "eW3CTraceFlags", + "eAttributeSource", + "AddAttributeResult", + "eAttributeFilter", + "eAttributeChangeOp", + "eOTelSamplingDecision", + "eOTelSpanKind", + "eOTelSpanStatusCode" ] }, "@microsoft/applicationinsights-perfmarkmeasure-js": { @@ -50,7 +58,9 @@ "constEnums": [] }, "@microsoft/applicationinsights-channel-js": { - "constEnums": [] + "constEnums": [ + "eSerializeType" + ] }, "@microsoft/applicationinsights-react-native": { "constEnums": [] @@ -64,10 +74,14 @@ "constEnums": [] }, "@microsoft/applicationinsights-analytics-js": { - "constEnums": [] + "constEnums": [ + "eRouteTraceStrategy" + ] }, "@microsoft/applicationinsights-web": { - "constEnums": [] + "constEnums": [ + "eMaxPropertyLengths" + ] }, "@microsoft/applicationinsights-react-js": { "constEnums": [] @@ -91,7 +105,8 @@ "_eExtendedInternalMessageId", "GuidStyle", "FieldValueSanitizerType", - "TransportType" + "TransportType", + "eMaxPropertyLengths" ] }, "@microsoft/1ds-post-js": { diff --git a/AISKU/OpenTelemetry_Trace_API_Implementation_Summary.md b/AISKU/OpenTelemetry_Trace_API_Implementation_Summary.md new file mode 100644 index 000000000..e6a2e242a --- /dev/null +++ b/AISKU/OpenTelemetry_Trace_API_Implementation_Summary.md @@ -0,0 +1,136 @@ +# OpenTelemetry Trace API Implementation Summary + +## ✅ Completed Implementation + +I have successfully added an OpenTelemetry-compatible "trace" property to the AISku instance that implements the standard OpenTelemetry trace API. + +## New Files Created + +### 1. `AISKU/src/OpenTelemetry/trace/ITrace.ts` +- **ITracer Interface**: Standard OpenTelemetry tracer interface with `startSpan()` method +- **ITrace Interface**: Standard OpenTelemetry trace interface with `getTracer()` method + +### 2. `AISKU/src/OpenTelemetry/trace/trace.ts` +- **ApplicationInsightsTracer Class**: Implements ITracer, delegates to ApplicationInsights core +- **ApplicationInsightsTrace Class**: Implements ITrace, manages tracer instances with caching + +## Modified Files + +### 1. `AISKU/src/AISku.ts` +- **Added trace property declaration** to the AppInsightsSku class +- **Added trace property implementation** using `objDefine()` in the dynamic proto section +- **Added imports** for trace-related classes + +### 2. `AISKU/src/IApplicationInsights.ts` +- **Added trace property** to the IApplicationInsights interface +- **Added import** for ApplicationInsightsTrace type + +### 3. `AISKU/src/applicationinsights-web.ts` +- **Exported trace interfaces** (ITrace, ITracer) for public API +- **Exported implementation classes** (ApplicationInsightsTrace, ApplicationInsightsTracer) + +### 4. `examples/dependency/src/appinsights-init.ts` +- **Added example functions** demonstrating the new trace API usage +- **Updated imports** to include trace interfaces + +## API Usage Examples + +### Basic Usage +```typescript +// Get the OpenTelemetry trace API +const trace = appInsights.trace; + +// Get a tracer instance +const tracer = trace.getTracer("my-service", "1.0.0"); + +// Create a span +const span = tracer.startSpan("operation-name", { + kind: SpanKind.SERVER, + attributes: { + "component": "user-service", + "operation": "checkout" + } +}); + +// Set additional attributes +span.setAttribute("user.id", "12345"); +span.setAttributes({ + "request.size": 1024, + "response.status": 200 +}); + +// End the span (automatically creates telemetry) +span.end(); +``` + +### Multiple Tracers +```typescript +const trace = appInsights.trace; + +// Get different tracers for different components +const userServiceTracer = trace.getTracer("user-service", "1.2.3"); +const paymentTracer = trace.getTracer("payment-service", "2.1.0"); + +// Create spans from different tracers +const userSpan = userServiceTracer.startSpan("validate-user"); +const paymentSpan = paymentTracer.startSpan("process-payment"); +``` + +## Technical Implementation Details + +### Tracer Caching +- **Smart Caching**: Tracers are cached by name@version combination +- **Memory Efficient**: Reuses tracer instances for same name/version +- **Multiple Services**: Supports different tracers for different components + +### Span Integration +- **Automatic Telemetry**: Spans automatically create trace telemetry when ended +- **Span Context**: Full span context (trace ID, span ID) included in telemetry +- **Attributes**: All span attributes included as custom properties +- **Timing**: Start time, end time, and duration automatically calculated + +### Delegation to Core +- **Seamless Integration**: Trace API delegates to existing `core.startSpan()` method +- **Consistent Behavior**: Same span implementation as direct core usage +- **Compatibility**: Works with existing trace provider setup + +## Benefits + +1. **Standard API**: Provides OpenTelemetry-compatible trace API +2. **Developer Experience**: Familiar interface for OpenTelemetry users +3. **Service Identification**: Different tracers for different services/components +4. **Automatic Telemetry**: Spans automatically generate ApplicationInsights telemetry +5. **Backward Compatible**: Existing `core.startSpan()` usage still works +6. **Type Safety**: Full TypeScript support with proper interfaces + +## Usage Pattern Comparison + +### Before (Direct Core Access) +```typescript +const span = appInsights.core.startSpan("operation", options); +``` + +### After (OpenTelemetry API) +```typescript +const tracer = appInsights.trace.getTracer("service-name", "1.0.0"); +const span = tracer.startSpan("operation", options); +``` + +Both approaches work and create the same telemetry, but the trace API provides better organization and follows OpenTelemetry standards. + +## Files Structure +``` +AISKU/src/ +├── OpenTelemetry/trace/ +│ ├── ITrace.ts # OpenTelemetry trace interfaces +│ ├── trace.ts # Implementation classes +│ ├── AppInsightsTraceProvider.ts # Factory function (existing) +│ └── span.ts # Inline span implementation (existing) +├── AISku.ts # Added trace property +├── IApplicationInsights.ts # Added trace property to interface +└── applicationinsights-web.ts # Exported trace APIs +``` + +## ✅ Status: COMPLETE + +The OpenTelemetry trace API has been successfully implemented and integrated into the ApplicationInsights JavaScript SDK. Users can now access the standard OpenTelemetry trace API through `appInsights.trace` and create spans using the familiar `getTracer().startSpan()` pattern. diff --git a/AISKU/SpanImplementation.md b/AISKU/SpanImplementation.md new file mode 100644 index 000000000..a8f36fe4e --- /dev/null +++ b/AISKU/SpanImplementation.md @@ -0,0 +1,156 @@ +# OpenTelemetry-like Span Implementation + +## Overview + +ApplicationInsights now includes an OpenTelemetry-like span implementation that uses a provider pattern. This allows different SKUs to provide their own span implementations while the core SDK manages the lifecycle. + +## Architecture + +### Provider Pattern + +The implementation uses a provider pattern similar to OpenTelemetry's TracerProvider: + +- **Core SDK**: Manages span lifecycle through `ITraceProvider` interface +- **Web Package**: Provides concrete `AppInsightsTraceProvider` implementation +- **Other SKUs**: Can provide their own implementations + +### Key Interfaces + +- `IOTelSpan`: OpenTelemetry-like span interface (simplified) +- `IOTelSpanContext`: Span context with trace information +- `ITraceProvider`: Provider interface for creating spans +- `SpanOptions`: Options for creating spans + +## Usage + +### 1. Setup the Provider + +```typescript +import { ApplicationInsights, AppInsightsTraceProvider } from "@microsoft/applicationinsights-web"; + +// Initialize ApplicationInsights +const appInsights = new ApplicationInsights({ + config: { + connectionString: "YOUR_CONNECTION_STRING_HERE" + } +}); +appInsights.loadAppInsights(); + +// Register the trace provider +const traceProvider = new AppInsightsTraceProvider(); +appInsights.appInsightsCore?.setTraceProvider(traceProvider); +``` + +### 2. Create and Use Spans + +```typescript +import { SpanKind } from "@microsoft/applicationinsights-core-js"; + +// Start a span +const span = appInsights.appInsightsCore?.startSpan("operation-name", { + kind: SpanKind.CLIENT, + attributes: { + "user.id": "12345", + "operation.type": "http-request" + } +}); + +if (span) { + try { + // Do some work... + span.setAttribute("result", "success"); + + // Create a child span + const childSpan = appInsights.appInsightsCore?.startSpan( + "child-operation", + { + kind: SpanKind.INTERNAL + }, + span.spanContext() // Parent context + ); + + if (childSpan) { + childSpan.setAttribute("step", "processing"); + childSpan.end(); + } + + } catch (error) { + span.setAttribute("error", true); + span.setAttribute("error.message", error.message); + } finally { + span.end(); + } +} +``` + +### 3. Check Provider Availability + +```typescript +// Check if a trace provider is registered +const provider = appInsights.appInsightsCore?.getTraceProvider(); +if (provider && provider.isAvailable()) { + console.log(`Provider: ${provider.getProviderId()}`); + // Use spans... +} else { + console.log("No trace provider available"); +} +``` + +## Provider Implementation + +To create a custom trace provider for a different SKU: + +```typescript +import { ITraceProvider, IOTelSpan, SpanOptions, IDistributedTraceContext } from "@microsoft/applicationinsights-core-js"; + +export class CustomTraceProvider implements ITraceProvider { + public getProviderId(): string { + return "custom-provider"; + } + + public isAvailable(): boolean { + return true; + } + + public createSpan( + name: string, + options?: SpanOptions, + parent?: IDistributedTraceContext + ): IOTelSpan { + // Return your custom span implementation + return new CustomSpan(name, options, parent); + } +} +``` + +## Span Interface + +The `IOTelSpan` interface provides these methods: + +```typescript +interface IOTelSpan { + // Core methods + spanContext(): IOTelSpanContext; + setAttribute(key: string, value: string | number | boolean): IOTelSpan; + setAttributes(attributes: Record): IOTelSpan; + updateName(name: string): IOTelSpan; + end(endTime?: number): void; + isRecording(): boolean; +} +``` + +## Differences from OpenTelemetry + +This is a simplified implementation focused on ApplicationInsights needs: + +- **Removed**: `addEvent()` and `setStatus()` methods +- **Simplified**: Attribute handling for ES5 compatibility +- **Focused**: Integration with ApplicationInsights telemetry system + +## Benefits + +1. **Flexibility**: Different SKUs can provide tailored implementations +2. **Clean Separation**: Core manages lifecycle, providers handle creation +3. **OpenTelemetry-like**: Familiar API for developers +4. **Extensible**: Easy to add new span providers +5. **Type Safe**: Full TypeScript support with proper interfaces diff --git a/AISKU/Tests/Manual/README.md b/AISKU/Tests/Manual/README.md new file mode 100644 index 000000000..090aacdcf --- /dev/null +++ b/AISKU/Tests/Manual/README.md @@ -0,0 +1,262 @@ +# Span API End-to-End (E2E) Tests + +This directory contains end-to-end tests for the new Span APIs that send real telemetry to Azure Application Insights (Breeze endpoint) for manual validation in the Azure Portal. + +## 📁 Files + +- **`SpanE2E.Tests.ts`** - Automated E2E test suite that can be configured to send real telemetry +- **`span-e2e-manual-test.html`** - Interactive HTML page for manual testing with visual feedback + +## 🚀 Quick Start - Manual HTML Testing + +The easiest way to test is using the interactive HTML page: + +1. **Get your Application Insights credentials**: + - Go to [Azure Portal](https://portal.azure.com) + - Navigate to your Application Insights resource (or create a new one) + - Copy the **Instrumentation Key** or **Connection String** from the Overview page + +2. **Open the test page**: + ```bash + # Option 1: Open directly in browser + open AISKU/Tests/Manual/span-e2e-manual-test.html + + # Option 2: Serve via local web server + cd AISKU/Tests/Manual + python -m http.server 8080 + # Then open http://localhost:8080/span-e2e-manual-test.html + ``` + +3. **Run tests**: + - Paste your Instrumentation Key or Connection String + - Click "Initialize SDK" + - Run individual tests or click "Run All Tests" + - Watch the output log for confirmation + +4. **View results in Azure Portal**: + - Wait 1-2 minutes for telemetry to arrive + - Go to your Application Insights resource + - Navigate to **Performance** → **Dependencies** or **Requests** + - Use **Search** to find specific test scenarios + - Click **"View in End-to-End Transaction"** to see distributed traces + +## 🧪 Automated Test Suite + +### Configuration + +To run the automated test suite with real telemetry: + +1. Open [`SpanE2E.Tests.ts`](../Unit/src/SpanE2E.Tests.ts) + +2. Update the configuration: + ```typescript + // Set to true to send real telemetry + private static readonly MANUAL_E2E_TEST = true; + + // Replace with your instrumentation key + private static readonly _instrumentationKey = "YOUR-IKEY-HERE"; + ``` + +3. Run the tests: + ```bash + # From repository root + rush build + rush test + ``` + +### Test Scenarios Included + +The test suite covers: + +#### Basic Span Tests +- ✅ CLIENT span → RemoteDependency +- ✅ SERVER span → Request +- ✅ Failed span → success=false + +#### Distributed Trace Tests +- ✅ Parent-child relationships +- ✅ 3-level nested hierarchy +- ✅ Context propagation + +#### HTTP Dependency Tests +- ✅ Various HTTP methods (GET, POST, PUT, DELETE) +- ✅ Multiple status codes (2xx, 4xx, 5xx) +- ✅ Full HTTP details (headers, body size, response time) + +#### Database Dependency Tests +- ✅ MySQL, PostgreSQL, MongoDB, Redis, SQL Server +- ✅ SQL statements and operations +- ✅ Slow query scenarios + +#### Complex Scenarios +- ✅ E-commerce checkout flow (7 dependencies) +- ✅ Mixed success and failure operations +- ✅ Rich custom properties for filtering + +## 🔍 What to Look For in the Portal + +### Performance Blade + +**Dependencies Tab**: +- Look for CLIENT, PRODUCER, and INTERNAL spans +- Verify dependency types (Http, mysql, postgresql, redis, etc.) +- Check duration, target, and result codes +- Examine custom properties in the details pane + +**Requests Tab**: +- Look for SERVER and CONSUMER spans +- Verify URLs, methods, and status codes +- Check success/failure status +- View response codes and durations + +### Search Feature + +Filter by custom properties to find specific test runs: +``` +customDimensions.test.scenario == "ecommerce" +customDimensions.test.timestamp >= datetime(2025-12-01) +customDimensions.business.tenant == "manual-test-corp" +``` + +### End-to-End Transaction View + +1. Click any request or dependency +2. Click **"View in End-to-End Transaction"** +3. See the complete distributed trace: + - Timeline showing span durations + - Parent-child relationships + - All related dependencies + - Custom properties at each level + +### Transaction Timeline + +Look for: +- ✅ Correct parent-child relationships (indentation) +- ✅ Proper span nesting (visual hierarchy) +- ✅ Accurate duration calculations +- ✅ Operation IDs matching across spans +- ✅ Custom dimensions preserved throughout + +## 📊 Expected Results + +### Test: Basic CLIENT Span +- **Portal Location**: Performance → Dependencies +- **Dependency Type**: "Dependency" or "Http" +- **Custom Properties**: test.scenario, test.timestamp + +### Test: Parent-Child Trace +- **Portal Location**: End-to-End Transaction view +- **Expected**: 1 Request + 2 Dependencies +- **Relationship**: Both children reference same parent operation.id + +### Test: E-commerce Checkout +- **Portal Location**: End-to-End Transaction view +- **Expected**: 1 Request + 7 Dependencies +- **Types**: Http (inventory, payment, email), Database (create order), Redis (cache) +- **Duration**: Parent spans entire operation + +### Test: Rich Custom Properties +- **Portal Location**: Search → Custom dimensions filter +- **Expected Properties**: + - business.tenant + - user.subscription + - feature.* flags + - performance.* metrics + +## 🐛 Troubleshooting + +### Telemetry not appearing in portal + +1. **Wait longer**: Initial ingestion can take 1-3 minutes +2. **Check time filter**: Ensure portal is showing last 30 minutes +3. **Verify iKey**: Confirm instrumentation key is correct +4. **Check browser console**: Look for SDK errors +5. **Flush telemetry**: Call `appInsights.flush()` in tests + +### SDK initialization fails + +1. **Valid credentials**: Verify instrumentation key format +2. **CORS issues**: Ensure application is running on http/https (not file://) +3. **Browser compatibility**: Use modern browser (Chrome, Edge, Firefox) + +### Missing custom properties + +1. **Property name limits**: Check for truncation (8192 char limit) +2. **Reserved names**: Some property names are filtered (http.*, db.*, microsoft.*) +3. **Type preservation**: Ensure values are correct types (string, number, boolean) + +## 📝 Adding New Test Scenarios + +To add a new E2E test scenario: + +1. **In SpanE2E.Tests.ts**: + ```typescript + this.testCase({ + name: "E2E: Your new scenario", + test: () => { + const span = this._ai.startSpan("E2E-YourScenario", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "test.scenario": "your-scenario", + "custom.property": "value" + } + }); + + if (span) { + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + } + + this._ai.flush(); + Assert.ok(span, "Span created"); + } + }); + ``` + +2. **In span-e2e-manual-test.html**: + ```javascript + function testYourScenario() { + if (!appInsights) return; + + const span = appInsights.startSpan('E2E-Manual-YourScenario', { + kind: 1, // CLIENT + attributes: { + 'test.scenario': 'your-scenario', + 'custom.property': 'value' + } + }); + + if (span) { + span.setStatus({ code: 1 }); + span.end(); + log('✅ Your scenario sent', 'success'); + } + appInsights.flush(); + } + ``` + +## 🎯 Best Practices + +1. **Use descriptive names**: Prefix test spans with "E2E-" or "Manual-" +2. **Include timestamps**: Add test.timestamp for filtering +3. **Add scenario tags**: Use test.scenario for grouping +4. **Flush after tests**: Always call `flush()` to send immediately +5. **Wait before checking**: Give telemetry 1-2 minutes to arrive +6. **Use unique identifiers**: Help distinguish between test runs +7. **Clean up regularly**: Archive or delete old test data + +## 🔗 Resources + +- [Application Insights Overview](https://docs.microsoft.com/azure/azure-monitor/app/app-insights-overview) +- [OpenTelemetry Specification](https://opentelemetry.io/docs/specs/otel/trace/api/) +- [Azure Portal](https://portal.azure.com) +- [Application Insights SDK Documentation](../../../README.md) + +## 🤝 Contributing + +When adding new E2E tests: +1. Follow existing naming conventions (E2E-* prefix) +2. Include relevant custom properties +3. Document expected portal behavior +4. Update this README with new scenarios +5. Test manually before committing diff --git a/AISKU/Tests/Manual/span-e2e-manual-test.html b/AISKU/Tests/Manual/span-e2e-manual-test.html new file mode 100644 index 000000000..00172a525 --- /dev/null +++ b/AISKU/Tests/Manual/span-e2e-manual-test.html @@ -0,0 +1,818 @@ + + + + + + Application Insights - Span API Manual E2E Test + + + +
+

🔍 Application Insights - Span API Manual E2E Test

+

Send real telemetry to Azure Application Insights and verify in the portal

+
+ +
+

⚙️ Configuration

+
+ + +

+ Get this from: Azure Portal → Your Application Insights resource → Overview → Instrumentation Key +

+
+ +
+
+ +
+

🧪 Test Scenarios

+ +

Basic Tests

+ + + + +

Distributed Trace Tests

+ + + +

Dependency Tests

+ + + +

Complex Scenarios

+ + + + +

Batch Actions

+
+ + +
+ +

Utilities

+ + +
+ +
+

📊 Statistics

+
+
+
0
+
Spans Created
+
+
+
0
+
Dependencies
+
+
+
0
+
Requests
+
+
+
0
+
Trace IDs
+
+
+
+ +
+

📝 Output Log

+
+ Ready to start testing. Initialize the SDK first. +
+
+ +
+

🔗 View Results

+

After running tests, wait 1-2 minutes for telemetry to appear in the portal.

+ Open Azure Portal + +
+ 💡 Tips for viewing in portal: +
    +
  • Go to Application Insights → Performance to see requests and dependencies
  • +
  • Use the Search feature to find specific operations by custom properties
  • +
  • Click "View in End-to-End Transaction" to see distributed traces
  • +
  • Use the Timeline view to see span relationships
  • +
  • Filter by operation name like "E2E-CheckoutRequest"
  • +
  • Check custom dimensions for test.scenario and test.timestamp
  • +
+
+
+ + + + + + + diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index fc551e102..67f25c883 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -46,18 +46,18 @@ function _loadPackageJson(cb:(isNightly: boolean, packageJson: any) => IPromise< } function _checkSize(checkType: string, maxSize: number, size: number, isNightly: boolean): void { - if (isNightly) { + if (isNightly) { maxSize += .5; } Assert.ok(size <= maxSize, `exceed ${maxSize} KB, current ${checkType} size is: ${size} KB`); -} +} export class AISKUSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 154; - private readonly MAX_BUNDLE_SIZE = 154; - private readonly MAX_RAW_DEFLATE_SIZE = 62; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 62; + private readonly MAX_RAW_SIZE = 174; + private readonly MAX_BUNDLE_SIZE = 174; + private readonly MAX_RAW_DEFLATE_SIZE = 70; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 70; private readonly rawFilePath = "../dist/es5/applicationinsights-web.min.js"; // Automatically updated by version scripts private readonly currentVer = "3.3.10"; diff --git a/AISKU/Tests/Unit/src/NonRecordingSpan.Tests.ts b/AISKU/Tests/Unit/src/NonRecordingSpan.Tests.ts new file mode 100644 index 000000000..12597ed63 --- /dev/null +++ b/AISKU/Tests/Unit/src/NonRecordingSpan.Tests.ts @@ -0,0 +1,773 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/applicationinsights-web"; +import { eOTelSpanKind, eOTelSpanStatusCode, ITelemetryItem } from "@microsoft/applicationinsights-core-js"; + +/** + * Comprehensive tests for non-recording span behavior + * + * Non-recording spans are used for: + * - Context propagation without telemetry overhead + * - Testing and debugging scenarios + * - Wrapping external span contexts + * - Performance-sensitive scenarios + */ +export class NonRecordingSpanTests extends AITestClass { + private static readonly _instrumentationKey = "b7170927-2d1c-44f1-acec-59f4e1751c11"; + private static readonly _connectionString = `InstrumentationKey=${NonRecordingSpanTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "NonRecordingSpanTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: NonRecordingSpanTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + } catch (e) { + console.error("Failed to initialize tests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addBasicNonRecordingTests(); + this.addAttributeOperationTests(); + this.addStatusAndNameTests(); + this.addSpanKindTests(); + this.addHierarchyTests(); + this.addTelemetryGenerationTests(); + this.addPerformanceTests(); + this.addEdgeCaseTests(); + } + + private addBasicNonRecordingTests(): void { + this.testCase({ + name: "NonRecording: span created with recording:false is not recording", + test: () => { + // Act + const span = this._ai.startSpan("non-recording-basic", { recording: false }); + + // Assert + Assert.ok(span, "Span should be created"); + Assert.ok(!span.isRecording(), "Span should not be recording"); + Assert.equal(span.name, "non-recording-basic", "Span name should be set"); + } + }); + + this.testCase({ + name: "NonRecording: default recording:true creates recording span", + test: () => { + // Act + const span = this._ai.startSpan("recording-default"); + + // Assert + Assert.ok(span, "Span should be created"); + Assert.ok(span.isRecording(), "Span should be recording by default"); + } + }); + + this.testCase({ + name: "NonRecording: explicit recording:true creates recording span", + test: () => { + // Act + const span = this._ai.startSpan("recording-explicit", { recording: true }); + + // Assert + Assert.ok(span, "Span should be created"); + Assert.ok(span.isRecording(), "Span should be recording"); + } + }); + + this.testCase({ + name: "NonRecording: isRecording() returns false throughout lifecycle", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-lifecycle", { recording: false }); + + // Act & Assert - Before operations + Assert.ok(!span.isRecording(), "Should not be recording initially"); + + // Perform operations + span!.setAttribute("key", "value"); + Assert.ok(!span.isRecording(), "Should not be recording after setAttribute"); + + span!.setStatus({ code: eOTelSpanStatusCode.OK }); + Assert.ok(!span.isRecording(), "Should not be recording after setStatus"); + + span!.updateName("new-name"); + Assert.ok(!span.isRecording(), "Should not be recording after updateName"); + + span!.end(); + Assert.ok(!span.isRecording(), "Should not be recording after end"); + } + }); + } + + private addAttributeOperationTests(): void { + this.testCase({ + name: "NonRecording: setAttribute does not store attributes", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-attrs", { recording: false }); + + // Act + span!.setAttribute("key1", "value1"); + span!.setAttribute("key2", 123); + span!.setAttribute("key3", true); + + // Assert + const attrs = span!.attributes; + Assert.ok(attrs, "Attributes object should exist"); + // Non-recording spans don't store attributes + Assert.equal(Object.keys(attrs).length, 0, "No attributes should be stored"); + } + }); + + this.testCase({ + name: "NonRecording: setAttributes does not store attributes", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-set-attrs", { recording: false }); + + // Act + span!.setAttributes({ + "attr1": "value1", + "attr2": 456, + "attr3": false, + "attr4": [1, 2, 3] + }); + + // Assert + const attrs = span!.attributes; + Assert.equal(Object.keys(attrs).length, 0, "No attributes should be stored"); + } + }); + + this.testCase({ + name: "NonRecording: setAttribute returns span for chaining", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-chain", { recording: false }); + + // Act + const result = span!.setAttribute("key", "value"); + + // Assert + Assert.equal(result, span, "setAttribute should return the span for chaining"); + } + }); + + this.testCase({ + name: "NonRecording: setAttributes returns span for chaining", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-chain-multi", { recording: false }); + + // Act + const result = span!.setAttributes({ "key1": "value1", "key2": "value2" }); + + // Assert + Assert.equal(result, span, "setAttributes should return the span for chaining"); + } + }); + + this.testCase({ + name: "NonRecording: multiple setAttribute calls increment dropped count", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-dropped", { recording: false }); + + // Act + span!.setAttribute("key1", "value1"); + span!.setAttribute("key2", "value2"); + span!.setAttribute("key3", "value3"); + span!.setAttributes({ "key4": "value4", "key5": "value5" }); + + // Assert + const droppedCount = span!.droppedAttributesCount; + Assert.ok(droppedCount >= 5, `At least 5 attributes should be dropped, got ${droppedCount}`); + } + }); + + this.testCase({ + name: "NonRecording: setAttribute after end() increments dropped count", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-after-end", { recording: false }); + span!.end(); + + // Act + span!.setAttribute("late-key", "late-value"); + + // Assert - Should not throw, just increment dropped count + Assert.ok(span.ended, "Span should be ended"); + Assert.ok(span.droppedAttributesCount > 0, "Dropped attribute count should be incremented"); + } + }); + } + + private addStatusAndNameTests(): void { + this.testCase({ + name: "NonRecording: setStatus changes status even when not recording", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-status", { recording: false }); + + // Act + span!.setStatus({ code: eOTelSpanStatusCode.ERROR, message: "Test error" }); + + // Assert + Assert.equal(span.status.code, eOTelSpanStatusCode.ERROR, "Status code should be set"); + Assert.equal(span.status.message, "Test error", "Status message should be set"); + } + }); + + this.testCase({ + name: "NonRecording: setStatus returns span for chaining", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-status-chain", { recording: false }); + + // Act + const result = span!.setStatus({ code: eOTelSpanStatusCode.OK }); + + // Assert + Assert.equal(result, span, "setStatus should return the span for chaining"); + } + }); + + this.testCase({ + name: "NonRecording: updateName changes name even when not recording", + test: () => { + // Arrange + const span = this._ai.startSpan("original-name", { recording: false }); + + // Act + span!.updateName("updated-name"); + + // Assert + Assert.equal(span.name, "updated-name", "Name should be updated"); + } + }); + + this.testCase({ + name: "NonRecording: updateName returns span for chaining", + test: () => { + // Arrange + const span = this._ai.startSpan("chain-name", { recording: false }); + + // Act + const result = span!.updateName("new-chain-name"); + + // Assert + Assert.equal(result, span, "updateName should return the span for chaining"); + } + }); + + this.testCase({ + name: "NonRecording: chained operations work correctly", + test: () => { + // Arrange + const span = this._ai.startSpan("chaining-test", { recording: false }); + + // Act + const result = span + .setAttribute("key1", "value1") + .setAttributes({ "key2": "value2" }) + .setStatus({ code: eOTelSpanStatusCode.OK }) + .updateName("chained-name"); + + // Assert + Assert.equal(result, span, "All operations should return the span"); + Assert.equal(span.name, "chained-name", "Name should be updated"); + Assert.equal(span.status.code, eOTelSpanStatusCode.OK, "Status should be set"); + } + }); + } + + private addSpanKindTests(): void { + this.testCase({ + name: "NonRecording: CLIENT kind non-recording span", + test: () => { + // Act + const span = this._ai.startSpan("client-non-recording", { + kind: eOTelSpanKind.CLIENT, + recording: false + }); + + // Assert + Assert.ok(span, "Span should be created"); + Assert.equal(span.kind, eOTelSpanKind.CLIENT, "Kind should be CLIENT"); + Assert.ok(!span.isRecording(), "Should not be recording"); + } + }); + + this.testCase({ + name: "NonRecording: SERVER kind non-recording span", + test: () => { + // Act + const span = this._ai.startSpan("server-non-recording", { + kind: eOTelSpanKind.SERVER, + recording: false + }); + + // Assert + Assert.equal(span.kind, eOTelSpanKind.SERVER, "Kind should be SERVER"); + Assert.ok(!span.isRecording(), "Should not be recording"); + } + }); + + this.testCase({ + name: "NonRecording: INTERNAL kind non-recording span", + test: () => { + // Act + const span = this._ai.startSpan("internal-non-recording", { + kind: eOTelSpanKind.INTERNAL, + recording: false + }); + + // Assert + Assert.equal(span.kind, eOTelSpanKind.INTERNAL, "Kind should be INTERNAL"); + Assert.ok(!span.isRecording(), "Should not be recording"); + } + }); + + this.testCase({ + name: "NonRecording: PRODUCER kind non-recording span", + test: () => { + // Act + const span = this._ai.startSpan("producer-non-recording", { + kind: eOTelSpanKind.PRODUCER, + recording: false + }); + + // Assert + Assert.equal(span.kind, eOTelSpanKind.PRODUCER, "Kind should be PRODUCER"); + Assert.ok(!span.isRecording(), "Should not be recording"); + } + }); + + this.testCase({ + name: "NonRecording: CONSUMER kind non-recording span", + test: () => { + // Act + const span = this._ai.startSpan("consumer-non-recording", { + kind: eOTelSpanKind.CONSUMER, + recording: false + }); + + // Assert + Assert.equal(span.kind, eOTelSpanKind.CONSUMER, "Kind should be CONSUMER"); + Assert.ok(!span.isRecording(), "Should not be recording"); + } + }); + } + + private addHierarchyTests(): void { + this.testCase({ + name: "NonRecording: parent recording, child non-recording", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("recording-parent", { + kind: eOTelSpanKind.SERVER, + recording: true + }); + const parentContext = parentSpan!.spanContext(); + + // Act + const childSpan = this._ai.startSpan("non-recording-child", { + kind: eOTelSpanKind.CLIENT, + recording: false + }, parentContext); + + // Assert + Assert.ok(parentSpan!.isRecording(), "Parent should be recording"); + Assert.ok(!childSpan.isRecording(), "Child should not be recording"); + Assert.equal(childSpan!.spanContext().traceId, parentContext.traceId, + "Child should share parent's trace ID"); + } + }); + + this.testCase({ + name: "NonRecording: parent non-recording, child recording", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("non-recording-parent", { + kind: eOTelSpanKind.SERVER, + recording: false + }); + const parentContext = parentSpan!.spanContext(); + + // Act + const childSpan = this._ai.startSpan("recording-child", { + kind: eOTelSpanKind.CLIENT, + recording: true + }, parentContext); + + // Assert + Assert.ok(!parentSpan.isRecording(), "Parent should not be recording"); + Assert.ok(childSpan!.isRecording(), "Child should be recording"); + Assert.equal(childSpan!.spanContext().traceId, parentContext.traceId, + "Child should share parent's trace ID"); + } + }); + + this.testCase({ + name: "NonRecording: both parent and child non-recording", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("non-recording-parent-2", { + recording: false + }); + const parentContext = parentSpan!.spanContext(); + + // Act + const childSpan = this._ai.startSpan("non-recording-child-2", { + recording: false + }, parentContext); + + // Assert + Assert.ok(!parentSpan.isRecording(), "Parent should not be recording"); + Assert.ok(!childSpan.isRecording(), "Child should not be recording"); + } + }); + + this.testCase({ + name: "NonRecording: multi-level hierarchy with mixed recording", + test: () => { + // Arrange + const level1 = this._ai.startSpan("level1-recording", { recording: true }); + const level1Context = level1!.spanContext(); + + const level2 = this._ai.startSpan("level2-non-recording", { + recording: false + }, level1Context); + const level2Context = level2!.spanContext(); + + const level3 = this._ai.startSpan("level3-recording", { + recording: true + }, level2Context); + + // Assert + Assert.ok(level1!.isRecording(), "Level 1 should be recording"); + Assert.ok(!level2.isRecording(), "Level 2 should not be recording"); + Assert.ok(level3!.isRecording(), "Level 3 should be recording"); + + // All should share the same trace ID + Assert.equal(level2!.spanContext().traceId, level1Context.traceId, + "Level 2 should share trace ID"); + Assert.equal(level3!.spanContext().traceId, level1Context.traceId, + "Level 3 should share trace ID"); + } + }); + } + + private addTelemetryGenerationTests(): void { + this.testCase({ + name: "NonRecording: no telemetry generated on end()", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("non-recording-no-telemetry", { + kind: eOTelSpanKind.CLIENT, + recording: false + }); + span!.setAttribute("should-not-appear", "in-telemetry"); + span!.setStatus({ code: eOTelSpanStatusCode.OK }); + span!.end(); + + // Assert + const telemetryItem = this._trackCalls.find( + item => item.baseData?.name === "non-recording-no-telemetry" + ); + Assert.ok(!telemetryItem, "Non-recording span should not generate telemetry"); + } + }); + + this.testCase({ + name: "NonRecording: recording span generates telemetry, non-recording does not", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const recordingSpan = this._ai.startSpan("recording-generates", { + kind: eOTelSpanKind.CLIENT, + recording: true + }); + recordingSpan.end(); + + const nonRecordingSpan = this._ai.startSpan("non-recording-silent", { + kind: eOTelSpanKind.CLIENT, + recording: false + }); + nonRecordingSpan.end(); + + // Assert + const recordingTelemetry = this._trackCalls.find( + item => item.baseData?.name === "recording-generates" + ); + const nonRecordingTelemetry = this._trackCalls.find( + item => item.baseData?.name === "non-recording-silent" + ); + + Assert.ok(recordingTelemetry, "Recording span should generate telemetry"); + Assert.ok(!nonRecordingTelemetry, "Non-recording span should not generate telemetry"); + } + }); + + this.testCase({ + name: "NonRecording: parent recording generates telemetry, child non-recording does not", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const parent = this._ai.startSpan("parent-with-telemetry", { + kind: eOTelSpanKind.SERVER, + recording: true + }); + const parentContext = parent.spanContext(); + + const child = this._ai.startSpan("child-without-telemetry", { + kind: eOTelSpanKind.CLIENT, + recording: false + }, parentContext); + + child.end(); + parent.end(); + + // Assert + const parentTelemetry = this._trackCalls.find( + item => item.baseData?.name === "parent-with-telemetry" + ); + const childTelemetry = this._trackCalls.find( + item => item.baseData?.name === "child-without-telemetry" + ); + + Assert.ok(parentTelemetry, "Parent recording span should generate telemetry"); + Assert.ok(!childTelemetry, "Child non-recording span should not generate telemetry"); + } + }); + } + + private addPerformanceTests(): void { + this.testCase({ + name: "NonRecording: multiple non-recording spans minimal overhead", + test: () => { + // Arrange + this._trackCalls = []; + const spanCount = 100; + + // Act + const startTime = Date.now(); + for (let i = 0; i < spanCount; i++) { + const span = this._ai.startSpan(`non-recording-perf-${i}`, { + recording: false + }); + span!.setAttribute("iteration", i); + span!.setStatus({ code: eOTelSpanStatusCode.OK }); + span!.end(); + } + const elapsed = Date.now() - startTime; + + // Assert + Assert.ok(elapsed < 1000, `Creating ${spanCount} non-recording spans should be fast, took ${elapsed}ms`); + Assert.equal(this._trackCalls.length, 0, "No telemetry should be generated for non-recording spans"); + } + }); + + this.testCase({ + name: "NonRecording: attribute operations are fast on non-recording spans", + test: () => { + // Arrange + const span = this._ai.startSpan("perf-attrs", { recording: false }); + const attrCount = 1000; + + // Act + const startTime = Date.now(); + for (let i = 0; i < attrCount; i++) { + span!.setAttribute(`key${i}`, `value${i}`); + } + const elapsed = Date.now() - startTime; + + // Assert + Assert.ok(elapsed < 500, `Setting ${attrCount} attributes should be fast, took ${elapsed}ms`); + Assert.equal(Object.keys(span.attributes).length, 0, "Attributes should not be stored"); + } + }); + } + + private addEdgeCaseTests(): void { + this.testCase({ + name: "NonRecording: end() can be called multiple times safely", + test: () => { + // Arrange + const span = this._ai.startSpan("multi-end", { recording: false }); + + // Act & Assert - Should not throw + span!.end(); + Assert.ok(span.ended, "Span should be ended"); + + span!.end(); + Assert.ok(span.ended, "Span should still be ended"); + + span!.end(); + Assert.ok(span.ended, "Span should still be ended"); + } + }); + + this.testCase({ + name: "NonRecording: operations after end() do not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("ops-after-end", { recording: false }); + span!.end(); + + // Act & Assert - Should not throw + span!.setAttribute("late-attr", "value"); + span!.setAttributes({ "late-attrs": "values" }); + span!.setStatus({ code: eOTelSpanStatusCode.ERROR }); + span!.updateName("late-name"); + + Assert.ok(span.ended, "Span should remain ended"); + } + }); + + this.testCase({ + name: "NonRecording: null and undefined attribute values handled", + test: () => { + // Arrange + const span = this._ai.startSpan("null-attrs", { recording: false }); + + // Act & Assert - Should not throw + span!.setAttribute("null-value", null as any); + span!.setAttribute("undefined-value", undefined as any); + span!.setAttributes({ + "null-in-set": null as any, + "undefined-in-set": undefined as any + }); + + Assert.ok(!span.isRecording(), "Span should still be non-recording"); + } + }); + + this.testCase({ + name: "NonRecording: empty string name allowed", + test: () => { + // Act + const span = this._ai.startSpan("", { recording: false }); + + // Assert + Assert.ok(span, "Span with empty name should be created"); + Assert.equal(span.name, "", "Name should be empty string"); + Assert.ok(!span.isRecording(), "Should not be recording"); + } + }); + + this.testCase({ + name: "NonRecording: special characters in span name", + test: () => { + // Arrange + const specialNames = [ + "span/with/slashes", + "span:with:colons", + "span-with-dashes", + "span.with.dots", + "span with spaces", + "span\twith\ttabs", + "span(with)parens", + "span[with]brackets", + "span{with}braces" + ]; + + // Act & Assert + specialNames.forEach(name => { + const span = this._ai.startSpan(name, { recording: false }); + Assert.ok(span, `Span with name '${name}' should be created`); + Assert.equal(span.name, name, "Name should be preserved"); + Assert.ok(!span.isRecording(), "Should not be recording"); + }); + } + }); + + this.testCase({ + name: "NonRecording: very long span name handled", + test: () => { + // Arrange + const longName = "a".repeat(10000); + + // Act + const span = this._ai.startSpan(longName, { recording: false }); + + // Assert + Assert.ok(span, "Span with very long name should be created"); + Assert.ok(!span.isRecording(), "Should not be recording"); + } + }); + + this.testCase({ + name: "NonRecording: spanContext() returns valid context", + test: () => { + // Act + const span = this._ai.startSpan("context-check", { recording: false }); + const context = span!.spanContext(); + + // Assert + Assert.ok(context, "Context should exist"); + Assert.ok(context.traceId, "Trace ID should exist"); + Assert.ok(context.spanId, "Span ID should exist"); + Assert.ok(context.traceId.length === 32, "Trace ID should be 32 characters"); + Assert.ok(context.spanId.length === 16, "Span ID should be 16 characters"); + } + }); + + this.testCase({ + name: "NonRecording: status object immutability", + test: () => { + // Arrange + const span = this._ai.startSpan("status-immutable", { recording: false }); + span!.setStatus({ code: eOTelSpanStatusCode.OK, message: "Initial" }); + + // Act + const status1 = span!.status; + span!.setStatus({ code: eOTelSpanStatusCode.ERROR, message: "Changed" }); + const status2 = span!.status; + + // Assert + Assert.equal(status1.code, eOTelSpanStatusCode.OK, "First status should be OK"); + Assert.equal(status2.code, eOTelSpanStatusCode.ERROR, "Second status should be ERROR"); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/OTelInit.Tests.ts b/AISKU/Tests/Unit/src/OTelInit.Tests.ts new file mode 100644 index 000000000..8af75a087 --- /dev/null +++ b/AISKU/Tests/Unit/src/OTelInit.Tests.ts @@ -0,0 +1,112 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/applicationinsights-web"; +import { eOTelSpanKind, eOTelSpanStatusCode, isTracingSuppressed, ITelemetryItem, unsuppressTracing } from "@microsoft/applicationinsights-core-js"; +import { objIs, setBypassLazyCache } from "@nevware21/ts-utils"; +import { AnalyticsPluginIdentifier, PropertiesPluginIdentifier } from "@microsoft/applicationinsights-common"; + +/** + * Integration Tests for Span APIs with Properties Plugin and Analytics Plugin + * + * Tests verify that span telemetry correctly integrates with: + * - PropertiesPlugin: session, user, device, application context + * - AnalyticsPlugin: telemetry creation, dependency tracking, page views + * - Telemetry Initializers: custom property injection + * - SDK configuration: sampling, disabled tracking, etc. + */ +export class OTelInitTests extends AITestClass { + private _ai!: ApplicationInsights; + + constructor(testName?: string) { + super(testName || "OTelInitTests"); + } + + public testInitialize() { + try { + setBypassLazyCache(true); + this.useFakeServer = true; + + this._ai = new ApplicationInsights({ + config: { + instrumentationKey: "test-ikey-123", + disableInstrumentationKeyValidation: true, + disableAjaxTracking: false, + disableXhr: false, + disableFetchTracking: false, + enableAutoRouteTracking: false, + disableExceptionTracking: false, + maxBatchInterval: 100, + enableDebug: false, + extensionConfig: { + ["AppInsightsPropertiesPlugin"]: { + accountId: "test-account-id" + } + }, + traceCfg: { + coreTrace: 1 + } as any + } + }); + + this._ai.loadAppInsights(); + } catch (e) { + Assert.ok(false, "Failed to initialize tests: " + e); + console.error("Failed to initialize tests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + setBypassLazyCache(false); + } + + public registerTests() { + this.testCase({ + name: "OTelInitTests", + test: () => { + Assert.ok(this._ai, "ApplicationInsights instance should be initialized"); + Assert.ok(this._ai.getPlugin(PropertiesPluginIdentifier), "PropertiesPlugin should be loaded"); + Assert.ok(this._ai.getPlugin(AnalyticsPluginIdentifier), "AnalyticsPlugin should be loaded"); + Assert.ok(!isTracingSuppressed(this._ai.core), "Tracing should not be suppressed by default"); + } + }); + + this.testCase({ + name: "Validate OTelApi", + test: () => { + const otelApi = this._ai.otelApi; + Assert.ok(otelApi, "OTel API should be available"); + Assert.ok(otelApi.cfg, "OTel configuration should be available"); + Assert.ok(objIs(this._ai, otelApi.host), "OTel API host should be the same as the SKU instance"); + Assert.ok(objIs(otelApi.cfg.traceCfg, this._ai.core.config.traceCfg), "OTel trace configuration should be the same as the SDK config"); + Assert.ok(objIs(otelApi.host.config, this._ai.config), "OTel API config should be the same as the SDK config"); + Assert.ok(objIs(otelApi.host.config, this._ai.core.config), "OTel API config should be the same as the SDK core config"); + Assert.ok(objIs(this._ai.config, this._ai.core.config), "SDK config should be the same as the SDK core config"); + } + }); + + this.testCase({ + name: "Validate Trace suppression", + test: () => { + const otelApi = this._ai.otelApi; + Assert.ok(otelApi, "OTel API should be available"); + Assert.equal(false, isTracingSuppressed(this._ai.core), "Tracing should not be suppressed by default"); + Assert.equal(false, otelApi.cfg.traceCfg?.suppressTracing, "supressTracing should be false by default"); + Assert.equal(false, this._ai.core.config.traceCfg.suppressTracing, "suppressTracing should be false by default"); + + this._ai.core.config.traceCfg.suppressTracing = true; + Assert.equal(true, isTracingSuppressed(this._ai.core), "Tracing should be suppressed when suppressTracing is set to true"); + Assert.equal(true, otelApi.cfg.traceCfg?.suppressTracing, "supressTracing should be true when suppressTracing is set to true"); + Assert.equal(true, this._ai.core.config.traceCfg.suppressTracing, "suppressTracing should be true when suppressTracing is set to true"); + + unsuppressTracing(this._ai.core); + Assert.equal(false, isTracingSuppressed(this._ai.core), "Tracing should not be suppressed after unsuppressTracing"); + Assert.equal(false, this._ai.core.config.traceCfg.suppressTracing, "suppressTracing should be false by default"); + Assert.equal(false, otelApi.cfg.traceCfg?.suppressTracing, "supressTracing should be false after unsuppressTracing"); + } + }); + + } +} diff --git a/AISKU/Tests/Unit/src/SpanContextPropagation.Tests.ts b/AISKU/Tests/Unit/src/SpanContextPropagation.Tests.ts new file mode 100644 index 000000000..8de152cfe --- /dev/null +++ b/AISKU/Tests/Unit/src/SpanContextPropagation.Tests.ts @@ -0,0 +1,733 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { IReadableSpan, IDistributedTraceContext, ITelemetryItem, asString } from "@microsoft/applicationinsights-core-js"; +import { createPromise, IPromise } from '@nevware21/ts-async'; + +export class SpanContextPropagationTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${SpanContextPropagationTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "SpanContextPropagationTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: SpanContextPropagationTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize tests: ' + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addParentChildRelationshipTests(); + this.addMultiLevelHierarchyTests(); + this.addSiblingSpanTests(); + this.addAsyncBoundaryTests(); + this.addContextPropagationTests(); + } + + private addParentChildRelationshipTests(): void { + this.testCase({ + name: "ParentChild: child span should inherit parent's traceId", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-span"); + Assert.ok(parentSpan, "Parent span should be created"); + + // Act + const parentContext = parentSpan!.spanContext(); + const childSpan = this._ai.startSpan("child-span", undefined, parentContext); + const childContext = childSpan!.spanContext(); + + // Assert + Assert.equal(childContext.traceId, parentContext.traceId, "Child span should inherit parent's traceId"); + Assert.notEqual(childContext.spanId, parentContext.spanId, "Child span should have different spanId from parent"); + + // Cleanup + childSpan?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "ParentChild: child span should have unique spanId", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-unique-id"); + const parentContext = parentSpan!.spanContext(); + + // Act + const child1 = this._ai.startSpan("child-1", undefined, parentContext); + const child2 = this._ai.startSpan("child-2", undefined, parentContext); + + const child1Context = child1!.spanContext(); + const child2Context = child2!.spanContext(); + + // Assert + Assert.notEqual(child1Context.spanId, child2Context.spanId, + "Sibling children should have unique spanIds"); + Assert.notEqual(child1Context.spanId, parentContext.spanId, + "Child 1 spanId should differ from parent"); + Assert.notEqual(child2Context.spanId, parentContext.spanId, + "Child 2 spanId should differ from parent"); + + // Cleanup + child1?.end(); + child2?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "ParentChild: child spans created via getTraceCtx", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-via-getTraceCtx"); + + this._ai.setActiveSpan(parentSpan!); + + // Act - Use getTraceCtx to get current context + const currentContext = this._ai.getTraceCtx(); + const childSpan = this._ai.startSpan("child-via-getTraceCtx", undefined, currentContext || undefined); + + // Assert + const parentContext = parentSpan!.spanContext(); + const childContext = childSpan!.spanContext(); + + Assert.equal(childContext.traceId, parentContext.traceId, + "Child should inherit traceId via getTraceCtx"); + Assert.notEqual(childContext.spanId, parentContext.spanId, + "Child should have unique spanId"); + + // Cleanup + childSpan?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "ParentChild: parent context should preserve traceFlags", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-traceflags"); + const parentContext = parentSpan!.spanContext(); + + // Act + const childSpan = this._ai.startSpan("child-traceflags", undefined, parentContext); + const childContext = childSpan!.spanContext(); + + // Assert + Assert.equal(childContext.traceFlags, parentContext.traceFlags, + "Child should preserve parent's traceFlags"); + + // Cleanup + childSpan?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "ParentChild: parent context should preserve traceState if present", + test: () => { + // Arrange - Create parent span + const parentSpan = this._ai.startSpan("parent-tracestate"); + const parentContext = parentSpan!.spanContext(); + + // Manually set traceState (if the implementation supports it) + if (parentContext.traceState !== undefined) { + // Act + const childSpan = this._ai.startSpan("child-tracestate", undefined, parentContext); + const childContext = childSpan!.spanContext(); + + // Assert + Assert.equal(asString(childContext.traceState), asString(parentContext.traceState), + "Child should preserve parent's traceState"); + + // Cleanup + childSpan?.end(); + } + + parentSpan?.end(); + } + }); + + this.testCase({ + name: "ParentChild: multiple children from same parent share traceId", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-multiple-children"); + const parentContext = parentSpan!.spanContext(); + + // Act - Create multiple children + const children: IReadableSpan[] = []; + for (let i = 0; i < 5; i++) { + const child = this._ai.startSpan(`child-${i}`, undefined, parentContext); + if (child) { + children.push(child); + } + } + + // Assert + children.forEach((child, index) => { + const childContext = child.spanContext(); + Assert.equal(childContext.traceId, parentContext.traceId, + `Child ${index} should have parent's traceId`); + }); + + // All children should have unique spanIds + for (let i = 0; i < children.length; i++) { + for (let j = i + 1; j < children.length; j++) { + const ctx1 = children[i].spanContext(); + const ctx2 = children[j].spanContext(); + Assert.notEqual(ctx1.spanId, ctx2.spanId, + `Child ${i} and child ${j} should have different spanIds`); + } + } + + // Cleanup + children.forEach(child => child.end()); + parentSpan?.end(); + } + }); + } + + private addMultiLevelHierarchyTests(): void { + this.testCase({ + name: "MultiLevel: grandchild inherits root traceId", + test: () => { + // Arrange & Act - Create 3-level hierarchy + const rootSpan = this._ai.startSpan("root-span"); + const rootContext = rootSpan!.spanContext(); + + const childSpan = this._ai.startSpan("child-span", undefined, rootContext); + const childContext = childSpan!.spanContext(); + + const grandchildSpan = this._ai.startSpan("grandchild-span", undefined, childContext); + const grandchildContext = grandchildSpan!.spanContext(); + + // Assert + Assert.equal(childContext.traceId, rootContext.traceId, + "Child should have root's traceId"); + Assert.equal(grandchildContext.traceId, rootContext.traceId, + "Grandchild should have root's traceId"); + + Assert.notEqual(childContext.spanId, rootContext.spanId, + "Child should have unique spanId"); + Assert.notEqual(grandchildContext.spanId, childContext.spanId, + "Grandchild should have unique spanId"); + Assert.notEqual(grandchildContext.spanId, rootContext.spanId, + "Grandchild spanId should differ from root"); + + // Cleanup + grandchildSpan?.end(); + childSpan?.end(); + rootSpan?.end(); + } + }); + + this.testCase({ + name: "MultiLevel: deep hierarchy maintains trace consistency", + test: () => { + // Arrange - Create deep hierarchy (5 levels) + const spans: IReadableSpan[] = []; + + // Act - Create root + const rootSpan = this._ai.startSpan("level-0-root"); + spans.push(rootSpan!); + + // Create nested spans + for (let i = 1; i <= 4; i++) { + const parentContext = spans[i - 1].spanContext(); + const childSpan = this._ai.startSpan(`level-${i}`, undefined, parentContext); + spans.push(childSpan!); + } + + // Assert - All spans share same traceId + const rootTraceId = spans[0].spanContext().traceId; + spans.forEach((span, index) => { + const context = span.spanContext(); + Assert.equal(context.traceId, rootTraceId, + `Level ${index} should have root traceId`); + }); + + // All spans should have unique spanIds + const spanIds = spans.map(span => span.spanContext().spanId); + const uniqueSpanIds = new Set(spanIds); + Assert.equal(uniqueSpanIds.size, spans.length, + "All spans should have unique spanIds"); + + // Cleanup + for (let i = spans.length - 1; i >= 0; i--) { + spans[i].end(); + } + } + }); + + this.testCase({ + name: "MultiLevel: intermediate span can be parent to multiple children", + test: () => { + // Arrange - Create hierarchy with branching + const rootSpan = this._ai.startSpan("root"); + const rootContext = rootSpan!.spanContext(); + + const intermediateSpan = this._ai.startSpan("intermediate", undefined, rootContext); + const intermediateContext = intermediateSpan!.spanContext(); + + // Act - Create multiple children from intermediate + const leaf1 = this._ai.startSpan("leaf-1", undefined, intermediateContext); + const leaf2 = this._ai.startSpan("leaf-2", undefined, intermediateContext); + const leaf3 = this._ai.startSpan("leaf-3", undefined, intermediateContext); + + // Assert + const leaf1Context = leaf1!.spanContext(); + const leaf2Context = leaf2!.spanContext(); + const leaf3Context = leaf3!.spanContext(); + + // All share same traceId + Assert.equal(leaf1Context.traceId, rootContext.traceId, + "Leaf 1 should have root traceId"); + Assert.equal(leaf2Context.traceId, rootContext.traceId, + "Leaf 2 should have root traceId"); + Assert.equal(leaf3Context.traceId, rootContext.traceId, + "Leaf 3 should have root traceId"); + + // All have unique spanIds + Assert.notEqual(leaf1Context.spanId, leaf2Context.spanId, + "Leaf 1 and 2 should have different spanIds"); + Assert.notEqual(leaf2Context.spanId, leaf3Context.spanId, + "Leaf 2 and 3 should have different spanIds"); + Assert.notEqual(leaf1Context.spanId, leaf3Context.spanId, + "Leaf 1 and 3 should have different spanIds"); + + // Cleanup + leaf3?.end(); + leaf2?.end(); + leaf1?.end(); + intermediateSpan?.end(); + rootSpan?.end(); + } + }); + } + + private addSiblingSpanTests(): void { + this.testCase({ + name: "Siblings: spans with same parent have same traceId", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-for-siblings"); + const parentContext = parentSpan!.spanContext(); + + // Act - Create sibling spans + const sibling1 = this._ai.startSpan("sibling-1", undefined, parentContext); + const sibling2 = this._ai.startSpan("sibling-2", undefined, parentContext); + const sibling3 = this._ai.startSpan("sibling-3", undefined, parentContext); + + // Assert + const ctx1 = sibling1!.spanContext(); + const ctx2 = sibling2!.spanContext(); + const ctx3 = sibling3!.spanContext(); + + Assert.equal(ctx1.traceId, parentContext.traceId, + "Sibling 1 should have parent's traceId"); + Assert.equal(ctx2.traceId, parentContext.traceId, + "Sibling 2 should have parent's traceId"); + Assert.equal(ctx3.traceId, parentContext.traceId, + "Sibling 3 should have parent's traceId"); + + // Cleanup + sibling3?.end(); + sibling2?.end(); + sibling1?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "Siblings: independent root spans have different traceIds", + test: () => { + // Act - Create independent root spans + const root1 = this._ai.startSpan("independent-root-1", { root: true }); + const root2 = this._ai.startSpan("independent-root-2", { root: true }); + const root3 = this._ai.startSpan("independent-root-3", { root: true }); + + // Assert + const ctx1 = root1!.spanContext(); + const ctx2 = root2!.spanContext(); + const ctx3 = root3!.spanContext(); + + Assert.notEqual(ctx1.traceId, ctx2.traceId, + "Independent root 1 and 2 should have different traceIds"); + Assert.notEqual(ctx2.traceId, ctx3.traceId, + "Independent root 2 and 3 should have different traceIds"); + Assert.notEqual(ctx1.traceId, ctx3.traceId, + "Independent root 1 and 3 should have different traceIds"); + + // Cleanup + root3?.end(); + root2?.end(); + root1?.end(); + } + }); + + this.testCase({ + name: "Siblings: sibling spans have unique spanIds", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-unique-siblings"); + const parentContext = parentSpan!.spanContext(); + + // Act - Create many sibling spans + const siblings: IReadableSpan[] = []; + for (let i = 0; i < 10; i++) { + const sibling = this._ai.startSpan(`sibling-${i}`, undefined, parentContext); + if (sibling) { + siblings.push(sibling); + } + } + + // Assert - All spanIds should be unique + const spanIds = siblings.map(s => s.spanContext().spanId); + const uniqueSpanIds = new Set(spanIds); + Assert.equal(uniqueSpanIds.size, siblings.length, + "All sibling spans should have unique spanIds"); + + // Cleanup + siblings.forEach(s => s.end()); + parentSpan?.end(); + } + }); + } + + private addAsyncBoundaryTests(): void { + this.testCase({ + name: "AsyncBoundary: context can be captured and used across async operations", + test: () => { + // Arrange + const rootSpan = this._ai.startSpan("async-root"); + const capturedContext = rootSpan!.spanContext(); + + // Act - Simulate async boundary by creating child later + return createPromise((resolve) => { + setTimeout(() => { + // Create child span using captured context + const childSpan = this._ai.startSpan("async-child", undefined, capturedContext); + const childContext = childSpan!.spanContext(); + + // Assert + Assert.equal(childContext.traceId, capturedContext.traceId, + "Child created after async boundary should have parent's traceId"); + + // Cleanup + childSpan?.end(); + rootSpan?.end(); + resolve(); + }, 10); + }); + } + }); + + this.testCase({ + name: "AsyncBoundary: getTraceCtx can capture context for async operations", + test: () => { + // Arrange + const rootSpan = this._ai.startSpan("async-getTraceCtx-root"); + this._ai.setActiveSpan(rootSpan!); + + // Capture context using getTraceCtx + const capturedContext = this._ai.getTraceCtx(); + + // Act - Simulate async operation + return createPromise((resolve) => { + setTimeout(() => { + // Use captured context in async boundary + const asyncSpan = this._ai.startSpan("async-operation", undefined, capturedContext || undefined); + const asyncContext = asyncSpan!.spanContext(); + + // Assert + Assert.equal(asyncContext.traceId, capturedContext.traceId, "Async span should inherit captured traceId"); + + // Cleanup + asyncSpan?.end(); + rootSpan?.end(); + resolve(); + }, 10); + }); + } + }); + + this.testCase({ + name: "AsyncBoundary: nested async operations maintain trace", + test: () => { + // Arrange + const rootSpan = this._ai.startSpan("nested-async-root"); + const rootContext = rootSpan!.spanContext(); + + // Act - Chain async operations + return createPromise((resolve) => { + setTimeout(() => { + const child1 = this._ai.startSpan("async-child-1", undefined, rootContext); + const child1Context = child1!.spanContext(); + + setTimeout(() => { + const child2 = this._ai.startSpan("async-child-2", undefined, child1Context); + const child2Context = child2!.spanContext(); + + // Assert + Assert.equal(child1Context.traceId, rootContext.traceId, + "First async child should have root traceId"); + Assert.equal(child2Context.traceId, rootContext.traceId, + "Second async child should have root traceId"); + + // Cleanup + child2?.end(); + child1?.end(); + rootSpan?.end(); + resolve(); + }, 10); + }, 10); + }); + } + }); + + this.testCase({ + name: "AsyncBoundary: parallel async operations share traceId", + test: () => { + // Arrange + const rootSpan = this._ai.startSpan("parallel-async-root"); + const rootContext = rootSpan!.spanContext(); + + // Act - Create parallel async operations + const promises: IPromise[] = []; + const childContexts: IDistributedTraceContext[] = []; + + for (let i = 0; i < 3; i++) { + const promise = createPromise((resolve) => { + setTimeout(() => { + const childSpan = this._ai.startSpan(`parallel-child-${i}`, undefined, rootContext); + childContexts.push(childSpan!.spanContext()); + childSpan?.end(); + resolve(); + }, 10 + i * 5); + }); + promises.push(promise); + } + + return Promise.all(promises).then(() => { + // Assert - All parallel children should share root traceId + childContexts.forEach((ctx, index) => { + Assert.equal(ctx.traceId, rootContext.traceId, + `Parallel child ${index} should have root traceId`); + }); + + // All should have unique spanIds + const spanIds = childContexts.map(ctx => ctx.spanId); + const uniqueSpanIds = new Set(spanIds); + Assert.equal(uniqueSpanIds.size, childContexts.length, + "Parallel children should have unique spanIds"); + + // Cleanup + rootSpan?.end(); + }); + } + }); + } + + private addContextPropagationTests(): void { + this.testCase({ + name: "ContextPropagation: explicit parent context overrides active context", + test: () => { + // Arrange - Create two independent traces + const trace1Root = this._ai.startSpan("trace-1-root", { root: true }); + const trace2Root = this._ai.startSpan("trace-2-root", { root: true }); + + this._ai.setActiveSpan(trace1Root!); + + // Act - Create child with explicit trace2 parent + const trace2Context = trace2Root!.spanContext(); + const childSpan = this._ai.startSpan("explicit-parent-child", undefined, trace2Context); + const childContext = childSpan!.spanContext(); + + // Assert - Child should belong to trace2, not active trace1 + Assert.equal(childContext.traceId, trace2Context.traceId, + "Explicit parent context should override active context"); + Assert.notEqual(childContext.traceId, trace1Root!.spanContext().traceId, + "Child should not belong to active trace"); + + // Cleanup + childSpan?.end(); + trace2Root?.end(); + trace1Root?.end(); + } + }); + + this.testCase({ + name: "ContextPropagation: spans without parent create new trace", + test: () => { + // Act - Create spans without explicit parent + const span1 = this._ai.startSpan("no-parent-1"); + const span2 = this._ai.startSpan("no-parent-2"); + + const ctx1 = span1!.spanContext(); + const ctx2 = span2!.spanContext(); + + // Assert - Should create independent traces or share active context + // (depends on implementation - both are valid) + Assert.ok(ctx1.traceId, "Span 1 should have traceId"); + Assert.ok(ctx2.traceId, "Span 2 should have traceId"); + Assert.ok(ctx1.spanId !== ctx2.spanId, + "Spans should have unique spanIds"); + + // Cleanup + span2?.end(); + span1?.end(); + } + }); + + this.testCase({ + name: "ContextPropagation: root option creates new trace", + test: () => { + // Arrange - Create parent span + const parentSpan = this._ai.startSpan("existing-parent"); + this._ai.setActiveSpan(parentSpan!); + + // Act - Create root span (should ignore active parent) + const rootSpan = this._ai.startSpan("new-root", { root: true }); + + const parentContext = parentSpan!.spanContext(); + const rootContext = rootSpan!.spanContext(); + + // Assert - Root span should have different traceId + Assert.notEqual(rootContext.traceId, parentContext.traceId, + "Root option should create new independent trace"); + + // Cleanup + rootSpan?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "ContextPropagation: context with all required fields propagates correctly", + test: () => { + // Arrange - Create context with all fields + const parentSpan = this._ai.startSpan("full-context-parent"); + const parentContext = parentSpan!.spanContext(); + + // Act - Create child + const childSpan = this._ai.startSpan("full-context-child", undefined, parentContext); + const childContext = childSpan!.spanContext(); + + // Assert - All fields should be present + Assert.ok(childContext.traceId, "Child should have traceId"); + Assert.ok(childContext.spanId, "Child should have spanId"); + Assert.equal(childContext.traceFlags, parentContext.traceFlags, + "Child should have traceFlags"); + + Assert.equal(childContext.traceId, parentContext.traceId, + "TraceId should propagate"); + Assert.equal(childContext.traceFlags, parentContext.traceFlags, + "TraceFlags should propagate"); + + // Cleanup + childSpan?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "ContextPropagation: recording attribute propagates independently", + test: () => { + // Arrange - Create recording parent + const recordingParent = this._ai.startSpan("recording-parent", { recording: true }); + const recordingContext = recordingParent!.spanContext(); + + // Act - Create non-recording child from recording parent + const nonRecordingChild = this._ai.startSpan("non-recording-child", + { recording: false }, recordingContext); + + // Assert - Recording is per-span, not propagated + Assert.ok(recordingParent!.isRecording(), + "Parent should be recording"); + Assert.ok(!nonRecordingChild!.isRecording(), + "Child should not be recording despite recording parent"); + + // But traceId should still propagate + Assert.equal(nonRecordingChild!.spanContext().traceId, recordingContext.traceId, + "TraceId should propagate regardless of recording state"); + + // Cleanup + nonRecordingChild?.end(); + recordingParent?.end(); + } + }); + + this.testCase({ + name: "ContextPropagation: span attributes do not propagate to children", + test: () => { + // Arrange - Create parent with attributes + const parentAttrs = { + "parent.attr1": "value1", + "parent.attr2": "value2" + }; + const parentSpan = this._ai.startSpan("parent-with-attrs", + { attributes: parentAttrs }); + const parentContext = parentSpan!.spanContext(); + + // Act - Create child with different attributes + const childAttrs = { + "child.attr1": "childValue1" + }; + const childSpan = this._ai.startSpan("child-with-attrs", + { attributes: childAttrs }, parentContext); + + // Assert - Attributes are per-span, not inherited + Assert.ok(parentSpan!.attributes["parent.attr1"] === "value1", + "Parent should have its attributes"); + Assert.ok(childSpan!.attributes["child.attr1"] === "childValue1", + "Child should have its attributes"); + Assert.ok(!childSpan!.attributes["parent.attr1"], + "Child should not inherit parent's attributes"); + + // But context should propagate + Assert.equal(childSpan!.spanContext().traceId, parentContext.traceId, + "TraceId should propagate"); + + // Cleanup + childSpan?.end(); + parentSpan?.end(); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/SpanE2E.Tests.ts b/AISKU/Tests/Unit/src/SpanE2E.Tests.ts new file mode 100644 index 000000000..e9389d0a5 --- /dev/null +++ b/AISKU/Tests/Unit/src/SpanE2E.Tests.ts @@ -0,0 +1,751 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/applicationinsights-web"; +import { eOTelSpanKind, eOTelSpanStatusCode } from "@microsoft/applicationinsights-core-js"; + +/** + * E2E Tests for Span APIs that send real telemetry to Breeze endpoint + * + * These tests can be run manually to verify telemetry appears correctly in the Azure Portal: + * 1. Set MANUAL_E2E_TEST to true + * 2. Replace the instrumentationKey with a valid test iKey + * 3. Run the tests + * 4. Check the Azure Portal for the telemetry within 1-2 minutes + * + * Look for: + * - Dependencies in the "Performance" blade + * - Requests in the "Performance" blade + * - Custom properties and measurements + * - Distributed trace correlation + * - End-to-end transaction view + */ +export class SpanE2ETests extends AITestClass { + // Set to true to actually send telemetry to Breeze for manual validation + private static readonly MANUAL_E2E_TEST = false; + + // Replace with your test instrumentation key for manual E2E testing + private static readonly _instrumentationKey = "b7170927-2d1c-44f1-acec-59f4e1751c11"; + private static readonly _connectionString = `InstrumentationKey=${SpanE2ETests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + + constructor(testName?: string) { + super(testName || "SpanE2ETests"); + } + + public testInitialize() { + try { + this.useFakeServer = !SpanE2ETests.MANUAL_E2E_TEST; + + this._ai = new ApplicationInsights({ + config: { + connectionString: SpanE2ETests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + disableFetchTracking: false, + enableAutoRouteTracking: true, + disableExceptionTracking: false, + maxBatchInterval: 1000, // Send quickly for manual testing + enableDebug: true, + loggingLevelConsole: 2 // Show warnings and errors + } + }); + + this._ai.loadAppInsights(); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("=== MANUAL E2E TEST MODE ==="); + console.log("Telemetry will be sent to Breeze endpoint"); + console.log("Check Azure Portal in 1-2 minutes"); + console.log("Instrumentation Key:", SpanE2ETests._instrumentationKey); + console.log("============================"); + } + } catch (e) { + console.error("Failed to initialize tests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + // Flush any pending telemetry before cleanup + this._ai.flush(); + this._ai.unload(false); + } + } + + public registerTests() { + this.addE2EBasicSpanTests(); + this.addE2EDistributedTraceTests(); + this.addE2EHttpDependencyTests(); + this.addE2EDatabaseDependencyTests(); + this.addE2EComplexScenarioTests(); + } + + private addE2EBasicSpanTests(): void { + this.testCase({ + name: "E2E: Basic CLIENT span creates RemoteDependency in portal", + test: () => { + // This will appear in the Azure Portal under Performance -> Dependencies + const span = this._ai.startSpan("E2E-BasicClientSpan", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "test.scenario": "basic-client", + "test.timestamp": new Date().toISOString(), + "test.type": "manual-validation", + "custom.property": "This should appear in custom properties" + } + }); + + // Simulate some work + if (span) { + span.setAttribute("work.completed", true); + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + } + + // Flush to ensure it's sent + this._ai.flush(); + + Assert.ok(span, "Span should be created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Basic CLIENT span sent - Check Azure Portal Dependencies"); + } + } + }); + + this.testCase({ + name: "E2E: Basic SERVER span creates Request in portal", + test: () => { + // This will appear in the Azure Portal under Performance -> Requests + const span = this._ai.startSpan("E2E-BasicServerSpan", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "POST", + "http.url": "https://example.com/api/test", + "http.status_code": 200, + "test.scenario": "basic-server", + "test.timestamp": new Date().toISOString() + } + }); + + if (span) { + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + } + + this._ai.flush(); + + Assert.ok(span, "Span should be created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Basic SERVER span sent - Check Azure Portal Requests"); + } + } + }); + + this.testCase({ + name: "E2E: Failed span shows as error in portal", + test: () => { + const span = this._ai.startSpan("E2E-FailedOperation", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "test.scenario": "failure-case", + "test.timestamp": new Date().toISOString() + } + }); + + if (span) { + // Simulate a failure + span.setAttribute("error.type", "TimeoutError"); + span.setAttribute("error.message", "Operation timed out after 5000ms"); + span.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Operation failed due to timeout" + }); + span.end(); + } + + this._ai.flush(); + + Assert.ok(span, "Span should be created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Failed span sent - Should show success=false in portal"); + } + } + }); + } + + private addE2EDistributedTraceTests(): void { + this.testCase({ + name: "E2E: Parent-child span relationship visible in portal", + test: () => { + // Create parent span + const parentSpan = this._ai.startSpan("E2E-ParentOperation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "test.scenario": "distributed-trace", + "test.timestamp": new Date().toISOString(), + "operation.level": "parent" + } + }); + + const parentContext = parentSpan?.spanContext(); + + // Create child span with explicit parent + const childSpan1 = this._ai.startSpan("E2E-ChildOperation1", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "operation.level": "child", + "child.index": 1 + } + }, parentContext); + + if (childSpan1) { + childSpan1.setAttribute("http.url", "https://api.example.com/users"); + childSpan1.setAttribute("http.method", "GET"); + childSpan1.setStatus({ code: eOTelSpanStatusCode.OK }); + childSpan1.end(); + } + + // Create another child + const childSpan2 = this._ai.startSpan("E2E-ChildOperation2", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "operation.level": "child", + "child.index": 2 + } + }, parentContext); + + if (childSpan2) { + childSpan2.setAttribute("http.url", "https://api.example.com/orders"); + childSpan2.setAttribute("http.method", "POST"); + childSpan2.setStatus({ code: eOTelSpanStatusCode.OK }); + childSpan2.end(); + } + + // End parent + if (parentSpan) { + parentSpan.setAttribute("children.count", 2); + parentSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + parentSpan.end(); + } + + this._ai.flush(); + + Assert.ok(parentSpan && childSpan1 && childSpan2, "All spans should be created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Distributed trace sent - Check End-to-End Transaction view"); + console.log(" Parent operation.id:", parentContext?.traceId); + } + } + }); + + this.testCase({ + name: "E2E: Nested span hierarchy (3 levels) visible in portal", + test: () => { + // Level 1: Root + const rootSpan = this._ai.startSpan("E2E-RootOperation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "test.scenario": "nested-hierarchy", + "test.timestamp": new Date().toISOString(), + "span.level": 1 + } + }); + + const rootContext = rootSpan?.spanContext(); + + // Level 2: Child + const level2Span = this._ai.startSpan("E2E-Level2Operation", { + kind: eOTelSpanKind.INTERNAL, + attributes: { + "span.level": 2 + } + }, rootContext); + + const level2Context = level2Span?.spanContext(); + + // Level 3: Grandchild + const level3Span = this._ai.startSpan("E2E-Level3Operation", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "span.level": 3, + "http.url": "https://api.example.com/deep-call" + } + }, level2Context); + + // End in reverse order (child first, parent last) + if (level3Span) { + level3Span.setStatus({ code: eOTelSpanStatusCode.OK }); + level3Span.end(); + } + + if (level2Span) { + level2Span.setStatus({ code: eOTelSpanStatusCode.OK }); + level2Span.end(); + } + + if (rootSpan) { + rootSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + rootSpan.end(); + } + + this._ai.flush(); + + Assert.ok(rootSpan && level2Span && level3Span, "All spans should be created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ 3-level nested trace sent - Check transaction timeline"); + } + } + }); + } + + private addE2EHttpDependencyTests(): void { + this.testCase({ + name: "E2E: HTTP dependency with full details in portal", + test: () => { + const span = this._ai.startSpan("E2E-HTTPDependency", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://api.example.com/v1/users/create", + "http.status_code": 201, + "http.request.header.content-type": "application/json", + "http.response.header.content-length": "1234", + "test.scenario": "http-dependency", + "test.timestamp": new Date().toISOString(), + "request.body.size": 512, + "response.time.ms": 145 + } + }); + + if (span) { + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + } + + this._ai.flush(); + + Assert.ok(span, "Span should be created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ HTTP dependency sent - Check Dependencies with full HTTP details"); + } + } + }); + + this.testCase({ + name: "E2E: HTTP dependency with various status codes in portal", + test: () => { + const statusCodes = [200, 201, 204, 400, 401, 403, 404, 500, 502, 503]; + + for (const statusCode of statusCodes) { + const isSuccess = statusCode >= 200 && statusCode < 400; + const span = this._ai.startSpan(`E2E-HTTP-${statusCode}`, { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "GET", + "http.url": `https://api.example.com/status/${statusCode}`, + "http.status_code": statusCode, + "test.scenario": "http-status-codes", + "test.timestamp": new Date().toISOString() + } + }); + + if (span) { + span.setStatus({ + code: isSuccess ? eOTelSpanStatusCode.OK : eOTelSpanStatusCode.ERROR + }); + span.end(); + } + } + + this._ai.flush(); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Multiple HTTP status codes sent - Check success/failure in portal"); + } + + Assert.ok(true, "Multiple status codes tested"); + } + }); + } + + private addE2EDatabaseDependencyTests(): void { + this.testCase({ + name: "E2E: Database dependencies appear in portal", + test: () => { + const databases = [ + { system: "mysql", statement: "SELECT * FROM users WHERE id = ?", name: "production_db" }, + { system: "postgresql", statement: "INSERT INTO logs (message, level) VALUES ($1, $2)", name: "logs_db" }, + { system: "mongodb", statement: "db.products.find({category: 'electronics'})", name: "catalog_db" }, + { system: "redis", statement: "GET user:session:abc123", name: "cache_db" }, + { system: "mssql", statement: "EXEC sp_GetUserOrders @UserId=123", name: "orders_db" } + ]; + + for (const db of databases) { + const span = this._ai.startSpan(`E2E-DB-${db.system}`, { + kind: eOTelSpanKind.CLIENT, + attributes: { + "db.system": db.system, + "db.statement": db.statement, + "db.name": db.name, + "db.user": "app_user", + "net.peer.name": `${db.system}.example.com`, + "net.peer.port": 5432, + "test.scenario": "database-dependencies", + "test.timestamp": new Date().toISOString() + } + }); + + if (span) { + span.setAttribute("db.rows.affected", 42); + span.setAttribute("db.duration.ms", 23); + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + } + } + + this._ai.flush(); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Database dependencies sent - Check Dependencies for SQL/NoSQL types"); + } + + Assert.ok(true, "Database dependencies tested"); + } + }); + + this.testCase({ + name: "E2E: Database slow query marked appropriately", + test: () => { + const span = this._ai.startSpan("E2E-SlowDatabaseQuery", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "db.system": "postgresql", + "db.statement": "SELECT * FROM orders JOIN users ON orders.user_id = users.id WHERE created_at > NOW() - INTERVAL '30 days'", + "db.name": "analytics_db", + "test.scenario": "slow-query", + "test.timestamp": new Date().toISOString(), + "db.query.execution.plan": "SeqScan on orders (cost=0.00..1000.00 rows=10000)", + "db.slow.query": true, + "db.duration.ms": 5432 + } + }); + + if (span) { + // Mark as warning (not error, but slow) + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.setAttribute("performance.warning", "Query exceeded 1000ms threshold"); + span.end(); + } + + this._ai.flush(); + + Assert.ok(span, "Slow query span created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Slow database query sent - Check duration in portal"); + } + } + }); + } + + private addE2EComplexScenarioTests(): void { + this.testCase({ + name: "E2E: Complex e-commerce checkout scenario in portal", + test: () => { + // Simulate a complete e-commerce checkout flow with multiple dependencies + const timestamp = new Date().toISOString(); + + // 1. Initial checkout request + const checkoutSpan = this._ai.startSpan("E2E-CheckoutRequest", { + kind: eOTelSpanKind.SERVER, + attributes: { + "test.scenario": "complex-ecommerce", + "test.timestamp": timestamp, + "http.method": "POST", + "http.url": "https://shop.example.com/api/checkout", + "http.status_code": 200, + "user.id": "user_12345", + "cart.items.count": 3, + "cart.total.amount": 299.97 + } + }); + + const checkoutContext = checkoutSpan?.spanContext(); + + // 2. Validate inventory + const inventorySpan = this._ai.startSpan("E2E-ValidateInventory", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://inventory-api.example.com/validate", + "http.status_code": 200, + "items.validated": 3 + } + }, checkoutContext); + + if (inventorySpan) { + inventorySpan.setStatus({ code: eOTelSpanStatusCode.OK }); + inventorySpan.end(); + } + + // 3. Calculate shipping + const shippingSpan = this._ai.startSpan("E2E-CalculateShipping", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://shipping-api.example.com/calculate", + "http.status_code": 200, + "shipping.method": "express", + "shipping.cost": 15.99 + } + }, checkoutContext); + + if (shippingSpan) { + shippingSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + shippingSpan.end(); + } + + // 4. Process payment + const paymentSpan = this._ai.startSpan("E2E-ProcessPayment", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://payments.example.com/charge", + "http.status_code": 200, + "payment.method": "credit_card", + "payment.amount": 315.96, + "payment.currency": "USD" + } + }, checkoutContext); + + if (paymentSpan) { + paymentSpan.setAttribute("payment.processor", "stripe"); + paymentSpan.setAttribute("payment.transaction.id", "txn_abc123xyz"); + paymentSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + paymentSpan.end(); + } + + // 5. Create order in database + const createOrderSpan = this._ai.startSpan("E2E-CreateOrder", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "db.system": "postgresql", + "db.statement": "INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING id", + "db.name": "orders_db", + "db.operation": "INSERT" + } + }, checkoutContext); + + if (createOrderSpan) { + createOrderSpan.setAttribute("order.id", "ord_98765"); + createOrderSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + createOrderSpan.end(); + } + + // 6. Send confirmation email + const emailSpan = this._ai.startSpan("E2E-SendConfirmationEmail", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://email-service.example.com/send", + "http.status_code": 202, + "email.recipient": "user@example.com", + "email.template": "order-confirmation" + } + }, checkoutContext); + + if (emailSpan) { + emailSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + emailSpan.end(); + } + + // 7. Update cache + const cacheSpan = this._ai.startSpan("E2E-UpdateCache", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "db.system": "redis", + "db.statement": "SET user:12345:last_order ord_98765 EX 86400", + "cache.operation": "set", + "cache.key": "user:12345:last_order" + } + }, checkoutContext); + + if (cacheSpan) { + cacheSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + cacheSpan.end(); + } + + // Complete checkout + if (checkoutSpan) { + checkoutSpan.setAttribute("checkout.status", "completed"); + checkoutSpan.setAttribute("order.id", "ord_98765"); + checkoutSpan.setAttribute("dependencies.count", 7); + checkoutSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + checkoutSpan.end(); + } + + this._ai.flush(); + + Assert.ok(checkoutSpan, "Checkout span created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Complex e-commerce scenario sent"); + console.log(" Trace ID:", checkoutContext?.traceId); + console.log(" Check End-to-End Transaction view for complete flow"); + console.log(" Expected: 1 Request + 7 Dependencies"); + } + } + }); + + this.testCase({ + name: "E2E: Mixed success and failure scenario in portal", + test: () => { + const timestamp = new Date().toISOString(); + + // Parent operation + const operationSpan = this._ai.startSpan("E2E-MixedResultsOperation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "test.scenario": "mixed-success-failure", + "test.timestamp": timestamp + } + }); + + const operationContext = operationSpan?.spanContext(); + + // Successful child 1 + const successSpan1 = this._ai.startSpan("E2E-SuccessfulCall1", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.url": "https://api.example.com/service1", + "http.status_code": 200 + } + }, operationContext); + + if (successSpan1) { + successSpan1.setStatus({ code: eOTelSpanStatusCode.OK }); + successSpan1.end(); + } + + // Failed child + const failedSpan = this._ai.startSpan("E2E-FailedCall", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.url": "https://api.example.com/service2", + "http.status_code": 503 + } + }, operationContext); + + if (failedSpan) { + failedSpan.setAttribute("error.type", "ServiceUnavailable"); + failedSpan.setAttribute("retry.count", 3); + failedSpan.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Service temporarily unavailable" + }); + failedSpan.end(); + } + + // Successful child 2 (after retry) + const successSpan2 = this._ai.startSpan("E2E-SuccessfulCall2", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.url": "https://api.example.com/service3", + "http.status_code": 200 + } + }, operationContext); + + if (successSpan2) { + successSpan2.setStatus({ code: eOTelSpanStatusCode.OK }); + successSpan2.end(); + } + + // Parent partially successful + if (operationSpan) { + operationSpan.setAttribute("successful.calls", 2); + operationSpan.setAttribute("failed.calls", 1); + operationSpan.setAttribute("total.calls", 3); + operationSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + operationSpan.end(); + } + + this._ai.flush(); + + Assert.ok(operationSpan, "Operation span created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Mixed success/failure scenario sent"); + console.log(" Check for 2 successful + 1 failed dependency in transaction"); + } + } + }); + + this.testCase({ + name: "E2E: Span with rich custom properties for portal search", + test: () => { + const span = this._ai.startSpan("E2E-RichProperties", { + kind: eOTelSpanKind.SERVER, + attributes: { + "test.scenario": "rich-properties", + "test.timestamp": new Date().toISOString(), + + // Business context + "business.tenant": "acme-corp", + "business.region": "us-west-2", + "business.environment": "production", + + // User context + "user.id": "user_12345", + "user.email": "test@example.com", + "user.subscription": "premium", + "user.account.age.days": 456, + + // Request context + "request.id": "req_abc123", + "request.source": "web-app", + "request.version": "v2.3.1", + + // Performance metrics + "performance.db.queries": 5, + "performance.cache.hits": 3, + "performance.cache.misses": 2, + "performance.total.ms": 234, + + // Feature flags + "feature.new.checkout": true, + "feature.ab.test.group": "variant-b", + + // Custom measurements + "metrics.items.processed": 42, + "metrics.data.size.kb": 128 + } + }); + + if (span) { + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + } + + this._ai.flush(); + + Assert.ok(span, "Span with rich properties created"); + + if (SpanE2ETests.MANUAL_E2E_TEST) { + console.log("✓ Span with rich properties sent"); + console.log(" Use Application Insights search to filter by custom properties"); + console.log(" Example queries:"); + console.log(" - customDimensions.business.tenant == 'acme-corp'"); + console.log(" - customDimensions.user.subscription == 'premium'"); + console.log(" - customDimensions.feature.new.checkout == true"); + } + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/SpanErrorHandling.Tests.ts b/AISKU/Tests/Unit/src/SpanErrorHandling.Tests.ts new file mode 100644 index 000000000..d6079a02f --- /dev/null +++ b/AISKU/Tests/Unit/src/SpanErrorHandling.Tests.ts @@ -0,0 +1,768 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { IReadableSpan, eOTelSpanStatusCode, ITelemetryItem } from "@microsoft/applicationinsights-core-js"; + +export class SpanErrorHandlingTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${SpanErrorHandlingTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "SpanErrorHandlingTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: SpanErrorHandlingTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize tests: ' + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addInvalidSpanNameTests(); + this.addInvalidAttributeTests(); + this.addNullUndefinedInputTests(); + this.addInvalidParentContextTests(); + this.addInvalidOptionsTests(); + this.addEdgeCaseTests(); + } + + private addInvalidSpanNameTests(): void { + this.testCase({ + name: "SpanName: empty string name should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan(""); + span?.end(); + }, "Empty string name should not throw"); + } + }); + + this.testCase({ + name: "SpanName: null name should handle gracefully", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan(null as any); + span?.end(); + }, "Null name should not throw"); + } + }); + + this.testCase({ + name: "SpanName: undefined name should handle gracefully", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan(undefined as any); + span?.end(); + }, "Undefined name should not throw"); + } + }); + + this.testCase({ + name: "SpanName: very long name should be accepted", + test: () => { + // Arrange + const longName = "a".repeat(10000); + + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan(longName); + Assert.ok(span, "Should create span with long name"); + span?.end(); + }, "Very long name should not throw"); + } + }); + + this.testCase({ + name: "SpanName: special characters in name should be accepted", + test: () => { + // Arrange + const specialNames = [ + "span-with-dashes", + "span_with_underscores", + "span.with.dots", + "span/with/slashes", + "span:with:colons", + "span@with@at", + "span#with#hash", + "span$with$dollar" + ]; + + // Act & Assert + specialNames.forEach(name => { + Assert.doesNotThrow(() => { + const span = this._ai.startSpan(name); + span?.end(); + }, `Special character name '${name}' should not throw`); + }); + } + }); + } + + private addInvalidAttributeTests(): void { + this.testCase({ + name: "Attributes: null attribute value should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("null-attribute-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute("nullable.attr", null); + }, "Setting null attribute should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: undefined attribute value should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("undefined-attribute-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute("undefined.attr", undefined); + }, "Setting undefined attribute should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: empty string attribute key should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("empty-key-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute("", "value"); + }, "Empty string key should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: null attribute key should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("null-key-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute(null as any, "value"); + }, "Null key should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: undefined attribute key should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("undefined-key-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute(undefined as any, "value"); + }, "Undefined key should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: invalid attribute value types should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("invalid-type-test"); + + // Act & Assert - Test various invalid types + Assert.doesNotThrow(() => { + span?.setAttribute("object.attr", { nested: "object" } as any); + span?.setAttribute("array.attr", [1, 2, 3] as any); + span?.setAttribute("function.attr", (() => {}) as any); + span?.setAttribute("symbol.attr", Symbol("test") as any); + }, "Invalid attribute types should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: setAttributes with null should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("setAttributes-null-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttributes(null as any); + }, "setAttributes with null should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: setAttributes with undefined should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("setAttributes-undefined-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttributes(undefined as any); + }, "setAttributes with undefined should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Attributes: setAttributes with invalid object should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("setAttributes-invalid-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttributes({ + "valid": "value", + "null.value": null, + "undefined.value": undefined, + "object.value": { nested: "obj" } as any + }); + }, "setAttributes with mixed valid/invalid should not throw"); + + // Cleanup + span?.end(); + } + }); + } + + private addNullUndefinedInputTests(): void { + this.testCase({ + name: "NullUndefined: startSpan with null options should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("null-options-test", null as any); + span?.end(); + }, "Null options should not throw"); + } + }); + + this.testCase({ + name: "NullUndefined: startSpan with undefined options should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("undefined-options-test", undefined); + span?.end(); + }, "Undefined options should not throw"); + } + }); + + this.testCase({ + name: "NullUndefined: setStatus with null should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("null-status-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setStatus(null as any); + }, "setStatus with null should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "NullUndefined: setStatus with undefined should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("undefined-status-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setStatus(undefined as any); + }, "setStatus with undefined should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "NullUndefined: updateName with null should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("null-name-update-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.updateName(null as any); + }, "updateName with null should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "NullUndefined: updateName with undefined should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("undefined-name-update-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.updateName(undefined as any); + }, "updateName with undefined should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "NullUndefined: end with null time should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("null-end-time-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.end(null as any); + }, "end with null time should not throw"); + } + }); + + this.testCase({ + name: "NullUndefined: end with undefined time should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("undefined-end-time-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.end(undefined); + }, "end with undefined time should not throw"); + } + }); + + this.testCase({ + name: "NullUndefined: recordException with null should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("null-exception-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.recordException(null as any); + }, "recordException with null should not throw"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "NullUndefined: recordException with undefined should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("undefined-exception-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.recordException(undefined as any); + }, "recordException with undefined should not throw"); + + // Cleanup + span?.end(); + } + }); + } + + private addInvalidParentContextTests(): void { + this.testCase({ + name: "ParentContext: null parent context should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("null-parent-test", undefined, null as any); + span?.end(); + }, "Null parent context should not throw"); + } + }); + + this.testCase({ + name: "ParentContext: undefined parent context should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("undefined-parent-test", undefined, undefined); + span?.end(); + }, "Undefined parent context should not throw"); + } + }); + + this.testCase({ + name: "ParentContext: invalid parent context object should not throw", + test: () => { + // Arrange - Create invalid context objects + const invalidContexts = [ + {}, + { traceId: "invalid" }, + { spanId: "invalid" }, + { traceId: "", spanId: "" }, + { traceId: "123", spanId: "456" } // Too short + ]; + + // Act & Assert + invalidContexts.forEach((ctx, index) => { + Assert.doesNotThrow(() => { + const span = this._ai.startSpan(`invalid-context-${index}`, undefined, ctx as any); + span?.end(); + }, `Invalid context ${index} should not throw`); + }); + } + }); + + this.testCase({ + name: "ParentContext: parent context with missing fields should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("missing-fields-test", undefined, { + traceId: "12345678901234567890123456789012" + // Missing spanId and traceFlags + } as any); + span?.end(); + }, "Parent context with missing fields should not throw"); + } + }); + + this.testCase({ + name: "ParentContext: parent context with wrong types should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("wrong-types-test", undefined, { + traceId: 123456789, // Should be string + spanId: 987654321, // Should be string + traceFlags: "invalid" // Should be number + } as any); + span?.end(); + }, "Parent context with wrong types should not throw"); + } + }); + } + + private addInvalidOptionsTests(): void { + this.testCase({ + name: "Options: invalid kind value should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("invalid-kind-test", { + kind: 999 as any // Invalid kind value + }); + span?.end(); + }, "Invalid kind value should not throw"); + } + }); + + this.testCase({ + name: "Options: negative kind value should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("negative-kind-test", { + kind: -1 as any + }); + span?.end(); + }, "Negative kind value should not throw"); + } + }); + + this.testCase({ + name: "Options: null attributes in options should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("null-attrs-options-test", { + attributes: null as any + }); + span?.end(); + }, "Null attributes in options should not throw"); + } + }); + + this.testCase({ + name: "Options: undefined attributes in options should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("undefined-attrs-options-test", { + attributes: undefined + }); + span?.end(); + }, "Undefined attributes in options should not throw"); + } + }); + + this.testCase({ + name: "Options: invalid startTime should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("invalid-starttime-test", { + startTime: "invalid" as any + }); + span?.end(); + }, "Invalid startTime should not throw"); + } + }); + + this.testCase({ + name: "Options: negative startTime should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("negative-starttime-test", { + startTime: -1000 + }); + span?.end(); + }, "Negative startTime should not throw"); + } + }); + + this.testCase({ + name: "Options: future startTime should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("future-starttime-test", { + startTime: Date.now() + 1000000 + }); + span?.end(); + }, "Future startTime should not throw"); + } + }); + + this.testCase({ + name: "Options: multiple invalid options should not throw", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const span = this._ai.startSpan("multi-invalid-options-test", { + kind: -999 as any, + attributes: null as any, + startTime: "invalid" as any, + recording: "maybe" as any, + root: "yes" as any + } as any); + span?.end(); + }, "Multiple invalid options should not throw"); + } + }); + } + + private addEdgeCaseTests(): void { + this.testCase({ + name: "EdgeCase: operations on null span should not throw", + test: () => { + // Arrange - Force null span (though SDK shouldn't return null) + const span: IReadableSpan | null = null; + + // Act & Assert - All operations should be safe + Assert.doesNotThrow(() => { + span?.setAttribute("key", "value"); + span?.setAttributes({ "key": "value" }); + span?.setStatus({ code: eOTelSpanStatusCode.OK }); + span?.updateName("new-name"); + span?.end(); + span?.recordException(new Error("test")); + }, "Operations on null span should not throw"); + } + }); + + this.testCase({ + name: "EdgeCase: extremely large attribute count should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("large-attr-count-test"); + const largeAttrs: any = {}; + for (let i = 0; i < 1000; i++) { + largeAttrs[`attr_${i}`] = `value_${i}`; + } + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttributes(largeAttrs); + span?.end(); + }, "Large attribute count should not throw"); + } + }); + + this.testCase({ + name: "EdgeCase: very long attribute values should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("long-attr-value-test"); + const longValue = "x".repeat(100000); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute("long.attr", longValue); + span?.end(); + }, "Very long attribute values should not throw"); + } + }); + + this.testCase({ + name: "EdgeCase: rapid successive operations should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("rapid-ops-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + for (let i = 0; i < 100; i++) { + span?.setAttribute(`rapid_${i}`, i); + span?.setStatus({ code: eOTelSpanStatusCode.OK }); + span?.updateName(`name_${i}`); + } + span?.end(); + }, "Rapid successive operations should not throw"); + } + }); + + this.testCase({ + name: "EdgeCase: mixed valid and invalid operations should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("mixed-ops-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute("valid", "value"); + span?.setAttribute(null as any, "invalid-key"); + span?.setAttribute("another.valid", 123); + span?.setAttribute("", "empty-key"); + span?.setAttributes({ "good": "attr", "bad": null }); + span?.setStatus({ code: eOTelSpanStatusCode.OK }); + span?.updateName(null as any); + span?.updateName("valid-name"); + span?.end(); + }, "Mixed valid and invalid operations should not throw"); + } + }); + + this.testCase({ + name: "EdgeCase: special Unicode characters should not throw", + test: () => { + // Arrange + const unicodeStrings = [ + "Hello 世界", + "Emoji 😀🎉", + "RTL العربية", + "Combined ñ é ü", + "Zero-width\u200B\u200Ccharacters" + ]; + + // Act & Assert + unicodeStrings.forEach((str, index) => { + Assert.doesNotThrow(() => { + const span = this._ai.startSpan(str); + span?.setAttribute("unicode.attr", str); + span?.updateName(`unicode_${index}_${str}`); + span?.end(); + }, `Unicode string ${index} should not throw`); + }); + } + }); + + this.testCase({ + name: "EdgeCase: circular reference in error should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("circular-error-test"); + const circularError: any = new Error("Circular test"); + circularError.self = circularError; // Create circular reference + + // Act & Assert + Assert.doesNotThrow(() => { + span?.recordException(circularError); + span?.end(); + }, "Circular reference in error should not throw"); + } + }); + + this.testCase({ + name: "EdgeCase: NaN and Infinity values should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("special-numbers-test"); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute("nan.value", NaN as any); + span?.setAttribute("infinity.value", Infinity as any); + span?.setAttribute("neg.infinity.value", -Infinity as any); + span?.end(); + }, "NaN and Infinity values should not throw"); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/SpanHelperUtils.Tests.ts b/AISKU/Tests/Unit/src/SpanHelperUtils.Tests.ts new file mode 100644 index 000000000..b59b79b7b --- /dev/null +++ b/AISKU/Tests/Unit/src/SpanHelperUtils.Tests.ts @@ -0,0 +1,992 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/applicationinsights-web"; +import { + createDistributedTraceContext, + eOTelSpanKind, + eOTelSpanStatusCode, + isReadableSpan, + isSpanContextValid, + ITelemetryItem, + wrapSpanContext +} from "@microsoft/applicationinsights-core-js"; +import { IDistributedTraceInit } from "@microsoft/applicationinsights-core-js/src/JavaScriptSDK.Interfaces/IDistributedTraceContext"; + +/** + * Comprehensive tests for span helper utility functions + * + * Tests verify: + * - isSpanContextValid: validates span context + * - wrapSpanContext: wraps external span contexts + * - isReadableSpan: type guard for spans + * - createNonRecordingSpan: creates non-recording spans (tested via wrapSpanContext) + */ +export class SpanHelperUtilsTests extends AITestClass { + private static readonly _instrumentationKey = "b7170927-2d1c-44f1-acec-59f4e1751c11"; + private static readonly _connectionString = `InstrumentationKey=${SpanHelperUtilsTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "SpanHelperUtilsTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: SpanHelperUtilsTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + } catch (e) { + console.error("Failed to initialize tests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addIsSpanContextValidTests(); + this.addWrapSpanContextTests(); + this.addIsReadableSpanTests(); + this.addHelperIntegrationTests(); + } + + private addIsSpanContextValidTests(): void { + this.testCase({ + name: "isSpanContextValid: valid span context returns true", + test: () => { + // Arrange + const span = this._ai.startSpan("test-span"); + const spanContext = span?.spanContext(); + + // Act + const isValid = isSpanContextValid(spanContext!); + + // Assert + Assert.ok(isValid, "Valid span context should return true"); + span?.end(); + } + }); + + this.testCase({ + name: "isSpanContextValid: valid traceId and spanId returns true", + test: () => { + // Arrange - create valid context + const validContext = createDistributedTraceContext({ + traceId: "0123456789abcdef0123456789abcdef", // 32 hex chars + spanId: "0123456789abcdef", // 16 hex chars + traceFlags: 1 + }); + + // Act + const isValid = isSpanContextValid(validContext); + + // Assert + Assert.ok(isValid, "Context with valid IDs should return true"); + } + }); + + this.testCase({ + name: "isSpanContextValid: invalid traceId returns false", + test: () => { + // Arrange - traceId too short (use IDistributedTraceInit directly, not createDistributedTraceContext which validates) + const invalidContext: IDistributedTraceInit = { + traceId: "0123456789abcdef", // Only 16 chars (should be 32) + spanId: "0123456789abcdef", + traceFlags: 1 + }; + + // Act + const isValid = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!isValid, "Context with invalid traceId should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: invalid spanId returns false", + test: () => { + // Arrange - spanId too short (use IDistributedTraceInit directly, not createDistributedTraceContext which validates) + const invalidContext: IDistributedTraceInit = { + traceId: "0123456789abcdef0123456789abcdef", + spanId: "01234567", // Only 8 chars (should be 16) + traceFlags: 1 + }; + + // Act + const isValid = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!isValid, "Context with invalid spanId should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: all zeros traceId returns false", + test: () => { + // Arrange - all zeros is invalid per spec (use IDistributedTraceInit directly, not createDistributedTraceContext which validates) + const invalidContext: IDistributedTraceInit = { + traceId: "00000000000000000000000000000000", + spanId: "0123456789abcdef", + traceFlags: 1 + }; + + // Act + const isValid = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!isValid, "Context with all-zero traceId should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: all zeros spanId returns false", + test: () => { + // Arrange - all zeros is invalid per spec (use IDistributedTraceInit directly, not createDistributedTraceContext which validates) + const invalidContext: IDistributedTraceInit = { + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0000000000000000", + traceFlags: 1 + }; + + // Act + const isValid = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!isValid, "Context with all-zero spanId should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: null context returns false", + test: () => { + // Act + const isValid = isSpanContextValid(null as any); + + // Assert + Assert.ok(!isValid, "Null context should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: undefined context returns false", + test: () => { + // Act + const isValid = isSpanContextValid(undefined as any); + + // Assert + Assert.ok(!isValid, "Undefined context should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: empty traceId returns false", + test: () => { + // Arrange - use IDistributedTraceInit directly, not createDistributedTraceContext which validates + const invalidContext: IDistributedTraceInit = { + traceId: "", + spanId: "0123456789abcdef", + traceFlags: 1 + }; + + // Act + const isValid = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!isValid, "Empty traceId should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: empty spanId returns false", + test: () => { + // Arrange - use IDistributedTraceInit directly, not createDistributedTraceContext which validates + const invalidContext: IDistributedTraceInit = { + traceId: "0123456789abcdef0123456789abcdef", + spanId: "", + traceFlags: 1 + }; + + // Act + const isValid = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!isValid, "Empty spanId should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: non-hex characters in traceId returns false", + test: () => { + // Arrange - use IDistributedTraceInit directly, not createDistributedTraceContext which validates + const invalidContext: IDistributedTraceInit = { + traceId: "0123456789abcdefghij456789abcdef", // Contains g-j + spanId: "0123456789abcdef", + traceFlags: 1 + }; + + // Act + const isValid = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!isValid, "TraceId with non-hex chars should return false"); + } + }); + + this.testCase({ + name: "isSpanContextValid: uppercase hex characters are valid", + test: () => { + // Arrange + const validContext = createDistributedTraceContext({ + traceId: "0123456789ABCDEF0123456789ABCDEF", + spanId: "0123456789ABCDEF", + traceFlags: 1 + }); + + // Act + const isValid = isSpanContextValid(validContext); + + // Assert + Assert.ok(isValid, "Uppercase hex characters should be valid"); + } + }); + + this.testCase({ + name: "isSpanContextValid: mixed case hex characters are valid", + test: () => { + // Arrange + const validContext = createDistributedTraceContext({ + traceId: "0123456789AbCdEf0123456789AbCdEf", + spanId: "0123456789AbCdEf", + traceFlags: 1 + }); + + // Act + const isValid = isSpanContextValid(validContext); + + // Assert + Assert.ok(isValid, "Mixed case hex characters should be valid"); + } + }); + } + + private addWrapSpanContextTests(): void { + this.testCase({ + name: "wrapSpanContext: creates non-recording span from context", + test: () => { + // Arrange + const originalSpan = this._ai.startSpan("original-span"); + const spanContext = originalSpan?.spanContext(); + + // Act + const wrappedSpan = wrapSpanContext(this._ai.otelApi, spanContext!); + + // Assert + Assert.ok(wrappedSpan, "Wrapped span should be created"); + Assert.ok(!wrappedSpan.isRecording(), "Wrapped span should not be recording"); + Assert.equal(wrappedSpan.spanContext().traceId, spanContext?.traceId, "TraceId should match"); + Assert.equal(wrappedSpan.spanContext().spanId, spanContext?.spanId, "SpanId should match"); + + // Cleanup + originalSpan?.end(); + wrappedSpan.end(); + } + }); + + this.testCase({ + name: "wrapSpanContext: wrapped span name includes spanId", + test: () => { + // Arrange + const spanContext = createDistributedTraceContext({ + traceId: "0123456789abcdef0123456789abcdef", + spanId: "0123456789abcdef", + traceFlags: 1 + }); + + // Act + const wrappedSpan = wrapSpanContext(this._ai.otelApi, spanContext); + + // Assert + Assert.ok(wrappedSpan.name.includes(spanContext.spanId), + "Wrapped span name should include spanId"); + Assert.ok(wrappedSpan.name.includes("wrapped"), + "Wrapped span name should indicate it's wrapped"); + + wrappedSpan.end(); + } + }); + + this.testCase({ + name: "wrapSpanContext: wrapped span does not generate telemetry", + test: () => { + // Arrange + this._trackCalls = []; + const spanContext = createDistributedTraceContext({ + traceId: "abcdef0123456789abcdef0123456789", + spanId: "abcdef0123456789", + traceFlags: 1 + }); + + // Act + const wrappedSpan = wrapSpanContext(this._ai.otelApi, spanContext); + wrappedSpan.setAttribute("test", "value"); + wrappedSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + wrappedSpan.end(); + + // Assert + Assert.equal(this._trackCalls.length, 0, + "Wrapped span should not generate telemetry"); + } + }); + + this.testCase({ + name: "wrapSpanContext: wrapped span kind is INTERNAL", + test: () => { + // Arrange + const spanContext = createDistributedTraceContext({ + traceId: "fedcba9876543210fedcba9876543210", + spanId: "fedcba9876543210", + traceFlags: 1 + }); + + // Act + const wrappedSpan = wrapSpanContext(this._ai.otelApi, spanContext); + + // Assert + Assert.equal(wrappedSpan.kind, eOTelSpanKind.INTERNAL, + "Wrapped span should have INTERNAL kind"); + + wrappedSpan.end(); + } + }); + + this.testCase({ + name: "wrapSpanContext: can use wrapped span as parent", + test: () => { + // Arrange + const parentContext = createDistributedTraceContext({ + traceId: "1234567890abcdef1234567890abcdef", + spanId: "1234567890abcdef", + traceFlags: 1 + }); + const wrappedParent = wrapSpanContext(this._ai.otelApi, parentContext); + + // Act - create child with wrapped parent + const childSpan = this._ai.startSpan("child-span", { + kind: eOTelSpanKind.CLIENT + }, wrappedParent.spanContext()); + + // Assert + Assert.ok(childSpan, "Child span should be created"); + Assert.equal(childSpan.spanContext().traceId, parentContext.traceId, + "Child should have same traceId as wrapped parent"); + + // Cleanup + childSpan?.end(); + wrappedParent.end(); + } + }); + + this.testCase({ + name: "wrapSpanContext: wrapped span supports all span operations", + test: () => { + // Arrange + const spanContext = createDistributedTraceContext({ + traceId: "aabbccddeeff00112233445566778899", + spanId: "aabbccddeeff0011", + traceFlags: 1 + }); + const wrappedSpan = wrapSpanContext(this._ai.otelApi, spanContext); + + // Act - perform various operations + wrappedSpan.setAttribute("key1", "value1"); + wrappedSpan.setAttributes({ + "key2": 123, + "key3": true + }); + wrappedSpan.updateName("new-name"); + wrappedSpan.setStatus({ code: eOTelSpanStatusCode.OK, message: "Success" }); + wrappedSpan.recordException(new Error("Test error")); + + // Assert - operations should not throw + Assert.ok(true, "All operations should complete without error"); + Assert.equal(wrappedSpan.name, "new-name", "Name should be updated"); + + wrappedSpan.end(); + } + }); + + this.testCase({ + name: "wrapSpanContext: preserves traceFlags if present", + test: () => { + // Arrange + const spanContext = createDistributedTraceContext({ + traceId: "11112222333344445555666677778888", + spanId: "1111222233334444", + traceFlags: 1 // Sampled + }); + + // Act + const wrappedSpan = wrapSpanContext(this._ai.otelApi, spanContext); + + // Assert + Assert.equal(wrappedSpan.spanContext().traceFlags, 1, + "TraceFlags should be preserved"); + + wrappedSpan.end(); + } + }); + + this.testCase({ + name: "wrapSpanContext: multiple wrapped spans from same context are independent", + test: () => { + // Arrange + const spanContext = createDistributedTraceContext({ + traceId: "99887766554433221100ffeeddccbbaa", + spanId: "9988776655443322", + traceFlags: 1 + }); + + // Act + const wrapped1 = wrapSpanContext(this._ai.otelApi, spanContext); + const wrapped2 = wrapSpanContext(this._ai.otelApi, spanContext); + + // Assert - both wrap same context but are different span objects + Assert.notEqual(wrapped1, wrapped2, "Should create different span objects"); + Assert.equal(wrapped1.spanContext().traceId, wrapped2.spanContext().traceId, + "Both should have same traceId"); + Assert.equal(wrapped1.spanContext().spanId, wrapped2.spanContext().spanId, + "Both should have same spanId"); + + // Operations on one should not affect the other + wrapped1.updateName("wrapped-1"); + wrapped2.updateName("wrapped-2"); + Assert.equal(wrapped1.name, "wrapped-1", "First span name"); + Assert.equal(wrapped2.name, "wrapped-2", "Second span name"); + + wrapped1.end(); + wrapped2.end(); + } + }); + + this.testCase({ + name: "wrapSpanContext: can wrap context from external system", + test: () => { + // Arrange - simulate receiving context from external system (e.g., HTTP header) + const externalContext = createDistributedTraceContext({ + traceId: "00112233445566778899aabbccddeeff", + spanId: "0011223344556677", + traceFlags: 1 + }); + + // Act + const wrappedSpan = wrapSpanContext(this._ai.otelApi, externalContext); + + // Create child span to continue the trace + const childSpan = this._ai.startSpan("continue-external-trace", { + kind: eOTelSpanKind.SERVER + }, wrappedSpan.spanContext()); + + // Assert + Assert.equal(childSpan?.spanContext().traceId, externalContext.traceId, + "Should continue external trace"); + Assert.notEqual(childSpan?.spanContext().spanId, externalContext.spanId, + "Should have new spanId"); + + // Cleanup + childSpan?.end(); + wrappedSpan.end(); + } + }); + } + + private addIsReadableSpanTests(): void { + this.testCase({ + name: "isReadableSpan: valid span returns true", + test: () => { + // Arrange + const span = this._ai.startSpan("test-span"); + + // Act + const isValid = isReadableSpan(span); + + // Assert + Assert.ok(isValid, "Valid span should return true"); + + span?.end(); + } + }); + + this.testCase({ + name: "isReadableSpan: null returns false", + test: () => { + // Act + const isValid = isReadableSpan(null); + + // Assert + Assert.ok(!isValid, "Null should return false"); + } + }); + + this.testCase({ + name: "isReadableSpan: undefined returns false", + test: () => { + // Act + const isValid = isReadableSpan(undefined); + + // Assert + Assert.ok(!isValid, "Undefined should return false"); + } + }); + + this.testCase({ + name: "isReadableSpan: plain object returns false", + test: () => { + // Arrange + const notASpan = { + name: "fake-span", + kind: eOTelSpanKind.INTERNAL + }; + + // Act + const isValid = isReadableSpan(notASpan); + + // Assert + Assert.ok(!isValid, "Plain object should return false"); + } + }); + + this.testCase({ + name: "isReadableSpan: object with partial span interface returns false", + test: () => { + // Arrange - object with some but not all required properties + const partialSpan = { + name: "partial", + kind: eOTelSpanKind.CLIENT, + spanContext: () => ({ traceId: "123", spanId: "456" }), + // Missing: duration, ended, startTime, endTime, etc. + }; + + // Act + const isValid = isReadableSpan(partialSpan); + + // Assert + Assert.ok(!isValid, "Partial span interface should return false"); + } + }); + + this.testCase({ + name: "isReadableSpan: recording span returns true", + test: () => { + // Arrange + const recordingSpan = this._ai.startSpan("recording", { recording: true }); + + // Act + const isValid = isReadableSpan(recordingSpan); + + // Assert + Assert.ok(isValid, "Recording span should return true"); + + recordingSpan?.end(); + } + }); + + this.testCase({ + name: "isReadableSpan: non-recording span returns true", + test: () => { + // Arrange + const nonRecordingSpan = this._ai.startSpan("non-recording", { recording: false }); + + // Act + const isValid = isReadableSpan(nonRecordingSpan); + + // Assert + Assert.ok(isValid, "Non-recording span should return true"); + + nonRecordingSpan?.end(); + } + }); + + this.testCase({ + name: "isReadableSpan: wrapped span context returns true", + test: () => { + // Arrange + const spanContext = createDistributedTraceContext({ + traceId: "aabbccdd00112233aabbccdd00112233", + spanId: "aabbccdd00112233", + traceFlags: 1 + }); + const wrappedSpan = wrapSpanContext(this._ai.otelApi, spanContext); + + // Act + const isValid = isReadableSpan(wrappedSpan); + + // Assert + Assert.ok(isValid, "Wrapped span should return true"); + + wrappedSpan.end(); + } + }); + + this.testCase({ + name: "isReadableSpan: ended span returns true", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-span"); + span?.end(); + + // Act + const isValid = isReadableSpan(span); + + // Assert + Assert.ok(isValid, "Ended span should still return true"); + } + }); + + this.testCase({ + name: "isReadableSpan: span with all kinds returns true", + test: () => { + // Arrange & Act & Assert + const internalSpan = this._ai.startSpan("internal", { kind: eOTelSpanKind.INTERNAL }); + Assert.ok(isReadableSpan(internalSpan), "INTERNAL span should be valid"); + internalSpan?.end(); + + const clientSpan = this._ai.startSpan("client", { kind: eOTelSpanKind.CLIENT }); + Assert.ok(isReadableSpan(clientSpan), "CLIENT span should be valid"); + clientSpan?.end(); + + const serverSpan = this._ai.startSpan("server", { kind: eOTelSpanKind.SERVER }); + Assert.ok(isReadableSpan(serverSpan), "SERVER span should be valid"); + serverSpan?.end(); + + const producerSpan = this._ai.startSpan("producer", { kind: eOTelSpanKind.PRODUCER }); + Assert.ok(isReadableSpan(producerSpan), "PRODUCER span should be valid"); + producerSpan?.end(); + + const consumerSpan = this._ai.startSpan("consumer", { kind: eOTelSpanKind.CONSUMER }); + Assert.ok(isReadableSpan(consumerSpan), "CONSUMER span should be valid"); + consumerSpan?.end(); + } + }); + + this.testCase({ + name: "isReadableSpan: string returns false", + test: () => { + // Act + const isValid = isReadableSpan("not a span"); + + // Assert + Assert.ok(!isValid, "String should return false"); + } + }); + + this.testCase({ + name: "isReadableSpan: number returns false", + test: () => { + // Act + const isValid = isReadableSpan(12345); + + // Assert + Assert.ok(!isValid, "Number should return false"); + } + }); + + this.testCase({ + name: "isReadableSpan: array returns false", + test: () => { + // Act + const isValid = isReadableSpan([]); + + // Assert + Assert.ok(!isValid, "Array should return false"); + } + }); + + this.testCase({ + name: "isReadableSpan: function returns false", + test: () => { + // Act + const isValid = isReadableSpan(() => {}); + + // Assert + Assert.ok(!isValid, "Function should return false"); + } + }); + } + + private addHelperIntegrationTests(): void { + this.testCase({ + name: "Integration: validate context before wrapping", + test: () => { + // Arrange - good practice to validate before wrapping + const validContext: IDistributedTraceInit = { + traceId: "aaaabbbbccccddddeeeeffffaaaabbbb", + spanId: "aaaabbbbccccdddd", + traceFlags: 1 + }; + const invalidContext: IDistributedTraceInit = { + traceId: "invalid", + spanId: "also-bad", + traceFlags: 1 + }; + + // Act & Assert - validate before wrapping + Assert.ok(isSpanContextValid(validContext), "Valid context should pass validation"); + const wrappedSpan = wrapSpanContext(this._ai.otelApi, validContext); + Assert.ok(wrappedSpan, "Should wrap valid context"); + Assert.ok(isReadableSpan(wrappedSpan), "Wrapped span should be readable"); + wrappedSpan.end(); + + // Don't wrap invalid context + Assert.ok(!isSpanContextValid(invalidContext), + "Should detect invalid context before wrapping"); + } + }); + + this.testCase({ + name: "Integration: type-safe span handling with isReadableSpan", + test: () => { + // Arrange + const span = this._ai.startSpan("type-safe"); + const maybeSpan: any = span; + + // Act - type guard allows safe access + if (isReadableSpan(maybeSpan)) { + // TypeScript knows this is IReadableSpan now + const context = maybeSpan.spanContext(); + maybeSpan.setAttribute("safe", "access"); + maybeSpan.end(); + + // Assert + Assert.ok(context.traceId, "Can safely access span properties"); + } else { + Assert.ok(false, "Span should be readable"); + } + } + }); + + this.testCase({ + name: "Integration: wrap external trace and continue locally", + test: () => { + // Arrange - simulate receiving trace context from external service + const externalContext = createDistributedTraceContext({ + traceId: "1234567890abcdef1234567890abcdef", + spanId: "1234567890abcdef", + traceFlags: 1 + }); + + // Act - validate and wrap + Assert.ok(isSpanContextValid(externalContext), + "External context should be valid"); + + const wrappedExternal = wrapSpanContext( + this._ai.otelApi, + externalContext + ); + Assert.ok(isReadableSpan(wrappedExternal), + "Wrapped external should be readable"); + + // Continue trace with local spans + const localSpan1 = this._ai.startSpan("local-processing", { + kind: eOTelSpanKind.SERVER + }, wrappedExternal.spanContext()); + + const localSpan2 = this._ai.startSpan("database-call", { + kind: eOTelSpanKind.CLIENT + }, localSpan1?.spanContext()); + + // Assert - trace continuity + Assert.equal(localSpan1?.spanContext().traceId, externalContext.traceId, + "Local span should continue external trace"); + Assert.equal(localSpan2?.spanContext().traceId, externalContext.traceId, + "Nested span should continue external trace"); + Assert.notEqual(localSpan2?.spanContext().spanId, externalContext.spanId, + "Should have new span IDs"); + + // Cleanup + localSpan2?.end(); + localSpan1?.end(); + wrappedExternal.end(); + } + }); + + this.testCase({ + name: "Integration: helper functions work with all span kinds", + test: () => { + // Test each span kind + const kinds = [ + eOTelSpanKind.INTERNAL, + eOTelSpanKind.CLIENT, + eOTelSpanKind.SERVER, + eOTelSpanKind.PRODUCER, + eOTelSpanKind.CONSUMER + ]; + + for (const kind of kinds) { + // Create span + const span = this._ai.startSpan(`span-kind-${kind}`, { kind }); + Assert.ok(span, `Span with kind ${kind} should be created`); + + // Verify with isReadableSpan + Assert.ok(isReadableSpan(span), `Span kind ${kind} should be readable`); + + // Get and validate context + const context = span?.spanContext(); + Assert.ok(isSpanContextValid(context!), + `Span kind ${kind} should have valid context`); + + // Wrap the context + const wrapped = wrapSpanContext(this._ai.otelApi, context!); + Assert.ok(isReadableSpan(wrapped), + `Wrapped span from kind ${kind} should be readable`); + + // Cleanup + span?.end(); + wrapped.end(); + } + + Assert.ok(true, "All span kinds tested successfully"); + } + }); + + this.testCase({ + name: "Integration: helpers work after span lifecycle", + test: () => { + // Arrange - create and end span + const span = this._ai.startSpan("lifecycle-test"); + const context = span?.spanContext(); + span?.end(); + + // Act & Assert - helpers should still work with ended span + Assert.ok(isReadableSpan(span), + "isReadableSpan should work with ended span"); + Assert.ok(isSpanContextValid(context!), + "isSpanContextValid should work with context from ended span"); + + const wrapped = wrapSpanContext(this._ai.otelApi, context!); + Assert.ok(isReadableSpan(wrapped), + "Can wrap context from ended span"); + + wrapped.end(); + } + }); + + this.testCase({ + name: "Integration: defensive programming with helpers", + test: () => { + // Arrange - potentially problematic inputs + const nullValue: any = null; + const undefinedValue: any = undefined; + const emptyObject: any = {}; + const wrongType: any = "not a span"; + + // Act & Assert - helpers should handle gracefully + Assert.ok(!isReadableSpan(nullValue), "Handle null"); + Assert.ok(!isReadableSpan(undefinedValue), "Handle undefined"); + Assert.ok(!isReadableSpan(emptyObject), "Handle empty object"); + Assert.ok(!isReadableSpan(wrongType), "Handle wrong type"); + + Assert.ok(!isSpanContextValid(nullValue), "Validate null context"); + Assert.ok(!isSpanContextValid(undefinedValue), "Validate undefined context"); + Assert.ok(!isSpanContextValid(emptyObject), "Validate empty context"); + Assert.ok(!isSpanContextValid(wrongType), "Validate wrong type context"); + } + }); + + this.testCase({ + name: "Integration: wrap and use as active span", + test: () => { + // Arrange + const externalContext = createDistributedTraceContext({ + traceId: "activespan123456789012345678901234", + spanId: "activespan123456", + traceFlags: 1 + }); + + // Act - wrap and set as active + const wrappedSpan = wrapSpanContext(this._ai.otelApi, externalContext); + const scope = this._ai.core.setActiveSpan(wrappedSpan); + + // Create child that should automatically get wrapped span as parent + const childSpan = this._ai.startSpan("auto-child", { + kind: eOTelSpanKind.INTERNAL + }); + + // Assert + Assert.equal(childSpan?.spanContext().traceId, externalContext.traceId, + "Child should inherit traceId from active wrapped span"); + + // Cleanup + childSpan?.end(); + scope?.restore(); + wrappedSpan.end(); + } + }); + + this.testCase({ + name: "Integration: validation chain for incoming distributed trace", + test: () => { + // Simulate complete flow of receiving and processing distributed trace + + // Step 1: Receive trace context (e.g., from HTTP headers) + const receivedContext = createDistributedTraceContext({ + traceId: "abcdef0123456789abcdef0123456789", + spanId: "abcdef0123456789", + traceFlags: 1 + }); + + // Step 2: Validate received context + Assert.ok(isSpanContextValid(receivedContext), "Received trace context should be valid"); + + // Step 3: Wrap context to create local span representation + const remoteSpan = wrapSpanContext(this._ai.otelApi, receivedContext); + Assert.ok(isReadableSpan(remoteSpan), "Failed to create readable span"); + + // Step 4: Create local server span as child + const serverSpan = this._ai.startSpan("handle-request", { + kind: eOTelSpanKind.SERVER + }, remoteSpan.spanContext()); + + Assert.ok(isReadableSpan(serverSpan), "Server span should be readable"); + + // Step 5: Verify trace continuity + const serverContext = serverSpan?.spanContext(); + Assert.ok(isSpanContextValid(serverContext!), + "Server span should have valid context"); + Assert.equal(serverContext?.traceId, receivedContext.traceId, + "Trace ID should be preserved across process boundary"); + + // Step 6: Complete request handling + serverSpan?.setAttribute("http.status_code", 200); + serverSpan?.setStatus({ code: eOTelSpanStatusCode.OK }); + serverSpan?.end(); + remoteSpan.end(); + + Assert.ok(true, "Complete distributed trace flow validated"); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/SpanLifeCycle.Tests.ts b/AISKU/Tests/Unit/src/SpanLifeCycle.Tests.ts new file mode 100644 index 000000000..34cc169b5 --- /dev/null +++ b/AISKU/Tests/Unit/src/SpanLifeCycle.Tests.ts @@ -0,0 +1,655 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { eOTelSpanStatusCode, ITelemetryItem } from "@microsoft/applicationinsights-core-js"; + +export class SpanLifeCycleTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${SpanLifeCycleTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "SpanLifeCycleTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: SpanLifeCycleTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize tests: ' + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addDoubleEndTests(); + this.addOperationsOnEndedSpansTests(); + this.addEndedPropertyTests(); + this.addIsRecordingAfterEndTests(); + this.addEndTimeTests(); + } + + private addDoubleEndTests(): void { + this.testCase({ + name: "DoubleEnd: calling end() twice should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("double-end-test"); + + // Act & Assert - First end should succeed + Assert.doesNotThrow(() => { + span?.end(); + }, "First end() should not throw"); + + // Second end should not throw but should be no-op + Assert.doesNotThrow(() => { + span?.end(); + }, "Second end() should not throw"); + } + }); + + this.testCase({ + name: "DoubleEnd: second end() should be no-op", + test: () => { + // Arrange + const span = this._ai.startSpan("double-end-noop"); + this._trackCalls = []; + + // Act - End twice + span?.end(); + const trackCountAfterFirst = this._trackCalls.length; + + span?.end(); + const trackCountAfterSecond = this._trackCalls.length; + + // Assert - Second end should not generate additional telemetry + Assert.equal(trackCountAfterSecond, trackCountAfterFirst, + "Second end() should not generate additional telemetry"); + } + }); + + this.testCase({ + name: "DoubleEnd: ended property remains true after second end", + test: () => { + // Arrange + const span = this._ai.startSpan("double-end-property"); + + // Act + span?.end(); + const endedAfterFirst = span?.ended; + + span?.end(); + const endedAfterSecond = span?.ended; + + // Assert + Assert.ok(endedAfterFirst, "Span should be ended after first end()"); + Assert.ok(endedAfterSecond, "Span should remain ended after second end()"); + } + }); + + this.testCase({ + name: "DoubleEnd: multiple end() calls are safe", + test: () => { + // Arrange + const span = this._ai.startSpan("multiple-end-test"); + + // Act & Assert - Multiple ends should all be safe + Assert.doesNotThrow(() => { + for (let i = 0; i < 10; i++) { + span?.end(); + } + }, "Multiple end() calls should not throw"); + + Assert.ok(span?.ended, "Span should be marked as ended"); + } + }); + + this.testCase({ + name: "DoubleEnd: end with different times only uses first", + test: () => { + // Arrange + const span = this._ai.startSpan("double-end-time"); + + // Act - End with specific time + const firstEndTime = Date.now(); + span?.end(firstEndTime); + const capturedEndTime1 = span?.endTime; + + // Try to end again with different time + const secondEndTime = Date.now() + 1000; + span?.end(secondEndTime); + const capturedEndTime2 = span?.endTime; + + // Assert - End time should not change + Assert.deepEqual(capturedEndTime1, capturedEndTime2, + "End time should not change on second end()"); + } + }); + } + + private addOperationsOnEndedSpansTests(): void { + this.testCase({ + name: "EndedSpan: setAttribute on ended span should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-setAttribute"); + span?.end(); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttribute("after.end", "value"); + }, "setAttribute should not throw on ended span"); + } + }); + + this.testCase({ + name: "EndedSpan: setAttribute on ended span should be no-op", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-setAttribute-noop"); + span?.setAttribute("before.end", "initialValue"); + + const attributesBeforeEnd = span?.attributes; + span?.end(); + + // Act + span?.setAttribute("after.end", "newValue"); + span?.setAttribute("before.end", "modifiedValue"); + + // Assert + const attributesAfterEnd = span?.attributes; + Assert.ok(!attributesAfterEnd["after.end"], + "New attribute should not be added after end"); + Assert.equal(attributesAfterEnd["before.end"], "initialValue", + "Existing attribute should not be modified after end"); + } + }); + + this.testCase({ + name: "EndedSpan: setAttributes on ended span should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-setAttributes"); + span?.end(); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setAttributes({ + "attr1": "value1", + "attr2": "value2" + }); + }, "setAttributes should not throw on ended span"); + } + }); + + this.testCase({ + name: "EndedSpan: setAttributes on ended span should be no-op", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-setAttributes-noop"); + span?.setAttributes({ "initial": "value" }); + span?.end(); + + // Act + span?.setAttributes({ + "after.end.1": "value1", + "after.end.2": "value2" + }); + + // Assert + const attributes = span?.attributes; + Assert.ok(!attributes["after.end.1"], + "Attributes should not be added after end"); + Assert.ok(!attributes["after.end.2"], + "Attributes should not be added after end"); + } + }); + + this.testCase({ + name: "EndedSpan: setStatus on ended span should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-setStatus"); + span?.end(); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Error after end" + }); + }, "setStatus should not throw on ended span"); + } + }); + + this.testCase({ + name: "EndedSpan: setStatus on ended span should be no-op", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-setStatus-noop"); + span?.setStatus({ + code: eOTelSpanStatusCode.OK, + message: "Initial status" + }); + + const statusBeforeEnd = span?.status; + span?.end(); + + // Act + span?.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Modified after end" + }); + + // Assert + const statusAfterEnd = span?.status; + Assert.equal(statusAfterEnd.code, statusBeforeEnd?.code, + "Status code should not change after end"); + } + }); + + this.testCase({ + name: "EndedSpan: updateName on ended span should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("original-name"); + span?.end(); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.updateName("new-name-after-end"); + }, "updateName should not throw on ended span"); + } + }); + + this.testCase({ + name: "EndedSpan: updateName on ended span should be no-op", + test: () => { + // Arrange + const originalName = "original-name-noop"; + const span = this._ai.startSpan(originalName); + span?.end(); + + // Act + span?.updateName("modified-name"); + + // Assert + Assert.equal(span?.name, originalName, + "Span name should not change after end"); + } + }); + + this.testCase({ + name: "EndedSpan: recordException on ended span should not throw", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-recordException"); + span?.end(); + + // Act & Assert + Assert.doesNotThrow(() => { + span?.recordException(new Error("Exception after end")); + }, "recordException should not throw on ended span"); + } + }); + + this.testCase({ + name: "EndedSpan: multiple operations on ended span should all be safe", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-multiple-ops"); + span?.end(); + + // Act & Assert - All operations should be safe + Assert.doesNotThrow(() => { + span?.setAttribute("key", "value"); + span?.setAttributes({ "key1": "val1", "key2": "val2" }); + span?.setStatus({ code: eOTelSpanStatusCode.ERROR }); + span?.updateName("new-name"); + span?.recordException(new Error("test")); + span?.end(); // Try to end again + }, "Multiple operations on ended span should not throw"); + } + }); + } + + private addEndedPropertyTests(): void { + this.testCase({ + name: "EndedProperty: span should not be ended initially", + test: () => { + // Arrange & Act + const span = this._ai.startSpan("initial-not-ended"); + + // Assert + Assert.ok(!span?.ended, "Span should not be ended initially"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "EndedProperty: span should be ended after end() call", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-after-call"); + + // Act + span?.end(); + + // Assert + Assert.ok(span?.ended, "Span should be ended after end() call"); + } + }); + + this.testCase({ + name: "EndedProperty: ended property is read-only", + test: () => { + // Arrange + const span = this._ai.startSpan("readonly-ended") as any; + + // Act - Try to modify ended property + const canModify = () => { + try { + span.ended = true; + return true; + } catch (e) { + return false; + } + }; + + // Assert + Assert.ok(!span.ended, "Should start not ended"); + // Property should be read-only (or modification has no effect) + canModify(); + Assert.ok(!span.ended, "Manual modification should not affect ended state"); + + // Cleanup + span.end(); + } + }); + + this.testCase({ + name: "EndedProperty: ended state persists across property reads", + test: () => { + // Arrange + const span = this._ai.startSpan("persistent-ended"); + span?.end(); + + // Act - Read ended property multiple times + const ended1 = span?.ended; + const ended2 = span?.ended; + const ended3 = span?.ended; + + // Assert + Assert.ok(ended1, "First read should show ended"); + Assert.ok(ended2, "Second read should show ended"); + Assert.ok(ended3, "Third read should show ended"); + } + }); + + this.testCase({ + name: "EndedProperty: recording and non-recording spans both have ended property", + test: () => { + // Arrange + const recordingSpan = this._ai.startSpan("recording", { recording: true }); + const nonRecordingSpan = this._ai.startSpan("non-recording", { recording: false }); + + // Act + recordingSpan?.end(); + nonRecordingSpan?.end(); + + // Assert + Assert.ok(recordingSpan?.ended, "Recording span should be ended"); + Assert.ok(nonRecordingSpan?.ended, "Non-recording span should be ended"); + } + }); + } + + private addIsRecordingAfterEndTests(): void { + this.testCase({ + name: "IsRecording: isRecording() returns false after end()", + test: () => { + // Arrange + const span = this._ai.startSpan("recording-test"); + const isRecordingBefore = span?.isRecording(); + + // Act + span?.end(); + const isRecordingAfter = span?.isRecording(); + + // Assert + Assert.ok(isRecordingBefore, "Span should be recording before end"); + Assert.ok(!isRecordingAfter, "Span should not be recording after end"); + } + }); + + this.testCase({ + name: "IsRecording: non-recording span stays non-recording after end", + test: () => { + // Arrange + const span = this._ai.startSpan("non-recording-test", { recording: false }); + const isRecordingBefore = span?.isRecording(); + + // Act + span?.end(); + const isRecordingAfter = span?.isRecording(); + + // Assert + Assert.ok(!isRecordingBefore, "Non-recording span should not be recording before end"); + Assert.ok(!isRecordingAfter, "Non-recording span should not be recording after end"); + } + }); + + this.testCase({ + name: "IsRecording: isRecording() consistent with ended state", + test: () => { + // Arrange + const span = this._ai.startSpan("recording-consistency"); + + // Assert initial state + Assert.ok(span?.isRecording(), "Should be recording when not ended"); + Assert.ok(!span?.ended, "Should not be ended initially"); + + // Act + span?.end(); + + // Assert final state + Assert.ok(!span?.isRecording(), "Should not be recording when ended"); + Assert.ok(span?.ended, "Should be ended after end()"); + } + }); + + this.testCase({ + name: "IsRecording: multiple isRecording() calls after end return consistent value", + test: () => { + // Arrange + const span = this._ai.startSpan("recording-multiple-calls"); + span?.end(); + + // Act + const check1 = span?.isRecording(); + const check2 = span?.isRecording(); + const check3 = span?.isRecording(); + + // Assert + Assert.ok(!check1, "First check should return false"); + Assert.ok(!check2, "Second check should return false"); + Assert.ok(!check3, "Third check should return false"); + } + }); + } + + private addEndTimeTests(): void { + this.testCase({ + name: "EndTime: endTime is undefined before end()", + test: () => { + // Arrange & Act + const span = this._ai.startSpan("endtime-undefined"); + const endTime = span?.endTime; + + // Assert + Assert.ok(endTime === undefined || endTime === null, + "endTime should be undefined/null before end()"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "EndTime: endTime is set after end()", + test: () => { + // Arrange + const span = this._ai.startSpan("endtime-set"); + + // Act + span?.end(); + const endTime = span?.endTime; + + // Assert + Assert.ok(endTime !== undefined && endTime !== null, + "endTime should be set after end()"); + } + }); + + this.testCase({ + name: "EndTime: endTime is after startTime", + test: () => { + // Arrange + const span = this._ai.startSpan("endtime-after-start"); + + // Act + span?.end(); + + // Assert + const startTime = span?.startTime; + const endTime = span?.endTime; + + if (startTime && endTime) { + // Compare HrTime [seconds, nanoseconds] + const startMs = startTime[0] * 1000 + startTime[1] / 1000000; + const endMs = endTime[0] * 1000 + endTime[1] / 1000000; + + Assert.ok(endMs >= startMs, + "endTime should be after or equal to startTime"); + } + } + }); + + this.testCase({ + name: "EndTime: custom endTime is respected", + test: () => { + // Arrange + const span = this._ai.startSpan("endtime-custom"); + const customEndTime = Date.now(); + + // Act + span?.end(customEndTime); + const actualEndTime = span?.endTime; + + // Assert + if (actualEndTime) { + const actualMs = actualEndTime[0] * 1000 + actualEndTime[1] / 1000000; + const diff = Math.abs(actualMs - customEndTime); + + Assert.ok(diff < 10, // Allow 10ms difference for conversion + "Custom endTime should be approximately respected"); + } + } + }); + + this.testCase({ + name: "EndTime: duration is calculated from startTime to endTime", + test: () => { + // Arrange + const span = this._ai.startSpan("duration-calculation"); + + // Act - Add small delay + const startTime = Date.now(); + for (let i = 0; i < 1000; i++) { + // Small busy loop + } + span?.end(); + + // Assert + const duration = span?.duration; + if (duration) { + const durationMs = duration[0] * 1000 + duration[1] / 1000000; + Assert.ok(durationMs >= 0, "Duration should be non-negative"); + } + } + }); + + this.testCase({ + name: "EndTime: endTime does not change after span is ended", + test: () => { + // Arrange + const span = this._ai.startSpan("endtime-immutable"); + + // Act + span?.end(); + const endTime1 = span?.endTime; + + // Try to end again (should be no-op) + span?.end(); + const endTime2 = span?.endTime; + + // Assert + Assert.deepEqual(endTime1, endTime2, + "endTime should not change after first end()"); + } + }); + + this.testCase({ + name: "EndTime: negative duration is handled gracefully", + test: () => { + // Arrange + const span = this._ai.startSpan("negative-duration"); + + // Act - End with time before start + const futureTime = Date.now() + 10000; + span?.end(); + + // Try to set past end time after span started + // (Note: SDK should handle this internally and prevent negative duration) + + // Assert + const duration = span?.duration; + if (duration) { + const durationMs = duration[0] * 1000 + duration[1] / 1000000; + Assert.ok(durationMs >= 0, + "Duration should never be negative (SDK should handle this)"); + } + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/SpanPluginIntegration.Tests.ts b/AISKU/Tests/Unit/src/SpanPluginIntegration.Tests.ts new file mode 100644 index 000000000..35b5fb077 --- /dev/null +++ b/AISKU/Tests/Unit/src/SpanPluginIntegration.Tests.ts @@ -0,0 +1,1020 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/applicationinsights-web"; +import { eOTelSpanKind, eOTelSpanStatusCode, isTracingSuppressed, ITelemetryItem } from "@microsoft/applicationinsights-core-js"; +import { setBypassLazyCache } from "@nevware21/ts-utils"; + +/** + * Integration Tests for Span APIs with Properties Plugin and Analytics Plugin + * + * Tests verify that span telemetry correctly integrates with: + * - PropertiesPlugin: session, user, device, application context + * - AnalyticsPlugin: telemetry creation, dependency tracking, page views + * - Telemetry Initializers: custom property injection + * - SDK configuration: sampling, disabled tracking, etc. + */ +export class SpanPluginIntegrationTests extends AITestClass { + private _ai!: ApplicationInsights; + + constructor(testName?: string) { + super(testName || "SpanPluginIntegrationTests"); + } + + public testInitialize() { + try { + setBypassLazyCache(true); + this.useFakeServer = true; + + this._ai = new ApplicationInsights({ + config: { + instrumentationKey: "test-ikey-123", + disableInstrumentationKeyValidation: true, + disableAjaxTracking: false, + disableXhr: false, + disableFetchTracking: false, + enableAutoRouteTracking: false, + disableExceptionTracking: false, + maxBatchInterval: 100, + enableDebug: false, + extensionConfig: { + ["AppInsightsPropertiesPlugin"]: { + accountId: "test-account-id" + } + } + } + }); + + this._ai.loadAppInsights(); + } catch (e) { + Assert.ok(false, "Failed to initialize tests: " + e); + console.error("Failed to initialize tests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + setBypassLazyCache(false); + } + + public registerTests() { + this.addPropertiesPluginIntegrationTests(); + this.addAnalyticsPluginIntegrationTests(); + this.addTelemetryInitializerTests(); + this.addSessionContextTests(); + this.addUserContextTests(); + this.addDeviceContextTests(); + this.addDistributedTraceContextTests(); + this.addSamplingIntegrationTests(); + this.addConfigurationIntegrationTests(); + } + + private addPropertiesPluginIntegrationTests(): void { + this.testCase({ + name: "PropertiesPlugin: span telemetry includes session context", + useFakeTimers: true, + useFakeServer: true, + test: () => { + const span = this._ai.startSpan("test-operation", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(span, "Span should be created"); + + Assert.equal(false, isTracingSuppressed(span), "Span should not be suppressed"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(1, sentItems.length, "Telemetry should be sent"); + + const payload = sentItems[0]; + Assert.ok(payload, "Payload should exist"); + Assert.ok(payload.tags, "Payload should have tags"); + + // Session ID is sent in tags with key "ai.session.id" + const sessionId = payload.tags["ai.session.id"]; + Assert.ok(sessionId, "Session ID should be in tags"); + Assert.ok(sessionId.length > 0, "Session ID should not be empty"); + } + }); + + this.testCase({ + name: "PropertiesPlugin: span telemetry includes user context", + useFakeTimers: true, + test: () => { + // Set user context before creating span + this._ai.context.user.authenticatedId = "test-auth-user-123"; + this._ai.context.user.accountId = "test-account-456"; + + const span = this._ai.startSpan("user-operation", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.setAttribute("custom.prop", "value"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(1, sentItems.length, "Telemetry should be sent"); + + const payload = sentItems[0]; + Assert.ok(payload, "Payload should exist"); + Assert.ok(payload.tags, "Payload should have tags"); + + // User auth ID is sent in tags with key "ai.user.authUserId" + const authUserId = payload.tags["ai.user.authUserId"]; + Assert.equal(authUserId, "test-auth-user-123", "Authenticated ID should match"); + + // Account ID is sent in tags with key "ai.user.accountId" + const accountId = payload.tags["ai.user.accountId"]; + Assert.equal(accountId, "test-account-456", "Account ID should be in tags"); + } + }); + + this.testCase({ + name: "PropertiesPlugin: span telemetry includes device context", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("device-operation", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(span, "Span should be created"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(1, sentItems.length, "Telemetry should be sent"); + const payload = sentItems[0]; + Assert.ok(payload, "Payload should exist"); + Assert.ok(payload.tags, "Payload should have tags"); + + // Device info is sent in tags with keys "ai.device.type" and "ai.device.id" + const deviceType = payload.tags["ai.device.type"]; + Assert.equal(deviceType, "Browser", "Device type should be Browser"); + + const deviceId = payload.tags["ai.device.id"]; + Assert.equal(deviceId, "browser", "Device ID should be browser"); + } + }); + + this.testCase({ + name: "PropertiesPlugin: span telemetry includes SDK version from internal context", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("sdk-version-check", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(1, sentItems.length, "Telemetry should be sent"); + const payload = sentItems[0]; + Assert.ok(payload, "Payload should exist"); + Assert.ok(payload.tags, "Payload should have tags"); + + // SDK version is sent in tags with key "ai.internal.sdkVersion" + const sdkVersion = payload.tags["ai.internal.sdkVersion"]; + Assert.ok(sdkVersion, "SDK version should exist"); + Assert.ok(sdkVersion.indexOf("javascript") >= 0 || sdkVersion.indexOf("ext1") >= 0, + "SDK version should contain javascript or extension prefix"); + } + }); + + this.testCase({ + name: "PropertiesPlugin: web context applied to span telemetry", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("web-context-operation", { + kind: eOTelSpanKind.SERVER + }); + Assert.ok(span, "Span should be created"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(1, sentItems.length, "Telemetry should be sent"); + + const payload = sentItems[0]; + Assert.ok(payload, "Payload should exist"); + + // Web context info like browser is sent in data section or tags + // Just verify the payload was sent successfully with telemetry + Assert.ok(payload.data, "Payload should have data section"); + } + }); + } + + private addAnalyticsPluginIntegrationTests(): void { + this.testCase({ + name: "AnalyticsPlugin: CLIENT span creates RemoteDependencyData", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("http-request", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "GET", + "http.url": "https://api.example.com/data", + "http.status_code": 200 + } + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Telemetry should be sent"); + + const item = sentItems[0] as ITelemetryItem; + Assert.ok(item.data, "Data should exist"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.equal(item.data!.baseType, "RemoteDependencyData", "BaseType should be RemoteDependencyData"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.ok(item.data!.baseData, "BaseData should exist"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.equal(item.data!.baseData.name, "GET /data", "Name should match span name"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.equal(item.data!.baseData.success, true, "Success should be true for OK status"); + } + }); + + this.testCase({ + name: "AnalyticsPlugin: PRODUCER span creates RemoteDependencyData with message type", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("send-message", { + kind: eOTelSpanKind.PRODUCER, + attributes: { + "messaging.system": "rabbitmq", + "messaging.destination": "orders-queue", + "messaging.operation": "send" + } + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Telemetry should be sent"); + + const item = sentItems[0] as ITelemetryItem; + Assert.ok(item.data, "Data should exist"); + if (!item.data) { + return; + } + Assert.equal(item.data.baseType, "RemoteDependencyData", "BaseType should be RemoteDependencyData"); + Assert.ok(item.data.baseData, "BaseData should exist"); + if (!item.data.baseData) { + return; + } + Assert.ok(item.data.baseData.type, "Type should be set for message dependency"); + } + }); + + this.testCase({ + name: "AnalyticsPlugin: custom properties merged into baseData", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("operation-with-props", { + kind: eOTelSpanKind.INTERNAL, + attributes: { + "custom.string": "value", + "custom.number": 42, + "custom.boolean": true + } + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + + span.setAttribute("runtime.prop", "added-after-start"); + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Telemetry should be sent"); + + const item = sentItems[0] as ITelemetryItem; + if (!item.data) { + return; + } + Assert.ok(item.data.baseData, "BaseData should exist"); + if (!item.data.baseData) { + return; + } + + // Custom properties should be in properties object + if (item.data.baseData.properties) { + Assert.equal(item.data.baseData.properties["custom.string"], "value", "String property should be preserved"); + Assert.equal(item.data.baseData.properties["custom.number"], 42, "Number property should be preserved"); + Assert.equal(item.data.baseData.properties["custom.boolean"], "true", "Boolean property should be preserved"); + Assert.equal(item.data.baseData.properties["runtime.prop"], "added-after-start", + "Runtime-added property should be present"); + } + } + }); + + this.testCase({ + name: "AnalyticsPlugin: span duration calculated correctly", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("timed-operation", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + + // Simulate some time passing + this.clock.tick(250); + + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Telemetry should be sent"); + + const item = sentItems[0] as ITelemetryItem; + if (!item.data) { + return; + } + Assert.ok(item.data.baseData, "BaseData should exist"); + if (!item.data.baseData) { + return; + } + Assert.equal(item.data.baseData.name, "timed-operation", "Name should match span name"); + Assert.ok(item.data.baseData.duration, "Duration should exist"); + + // Duration should be approximately 250ms (formatted as time span string) + const durationMs = this.parseDurationToMs(item.data.baseData.duration); + Assert.ok(durationMs >= 240 && durationMs <= 260, + "Duration should be ~250ms, got " + durationMs + "ms - " + JSON.stringify(item)); + } + }); + + this.testCase({ + name: "AnalyticsPlugin: failed span sets success=false", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("failing-operation", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + + span.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Operation failed" + }); + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Telemetry should be sent"); + + const item = sentItems[0] as ITelemetryItem; + if (!item.data) { + return; + } + Assert.ok(item.data.baseData, "BaseData should exist"); + if (!item.data.baseData) { + return; + } + Assert.equal(item.data.baseData.success, false, "Success should be false for ERROR status"); + } + }); + } + + private addTelemetryInitializerTests(): void { + this.testCase({ + name: "TelemetryInitializer: can modify span telemetry", + useFakeTimers: true, + test: () => { + let initializerCalled = false; + + this._ai.addTelemetryInitializer((item: ITelemetryItem) => { + initializerCalled = true; + + if (item.baseType === "RemoteDependencyData") { + // Add custom property via initializer + item.baseData = item.baseData || {}; + item.baseData.properties = item.baseData.properties || {}; + item.baseData.properties["initializer.added"] = "custom-value"; + item.baseData.properties["initializer.timestamp"] = new Date().toISOString(); + } + + return true; + }); + + const span = this._ai.startSpan("initialized-span", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + + span.end(); + this.clock.tick(500); + + Assert.ok(initializerCalled, "Telemetry initializer should be called"); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Telemetry should be sent"); + + const item = sentItems[0] as ITelemetryItem; + if (!item.data) { + return; + } + if (!item.data.baseData) { + return; + } + Assert.ok(item.data.baseData.properties, "Properties should exist"); + Assert.equal(item.data.baseData.properties["initializer.added"], "custom-value", + "Initializer-added property should be present"); + Assert.ok(item.data.baseData.properties["initializer.timestamp"], + "Timestamp should be added by initializer"); + } + }); + + this.testCase({ + name: "TelemetryInitializer: can filter span telemetry", + useFakeTimers: true, + test: () => { + this._ai.addTelemetryInitializer((item: ITelemetryItem) => { + // Filter out spans with specific attribute + if (item.baseType === "RemoteDependencyData" && + item.baseData && + item.baseData.properties && + item.baseData.properties["filter.me"] === "true") { + return false; // Reject this telemetry + } + return true; + }); + + // This span should be filtered out + const filteredSpan = this._ai.startSpan("filtered-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "filter.me": "true" + } + }); + Assert.ok(filteredSpan, "Filtered span should be created"); + if (filteredSpan) { + filteredSpan.end(); + } + + // This span should go through + const normalSpan = this._ai.startSpan("normal-span", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(normalSpan, "Normal span should be created"); + if (normalSpan) { + normalSpan.end(); + } + + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(sentItems.length, 1, "Only one span should be sent (filtered one rejected)"); + + const item = sentItems[0] as ITelemetryItem; + if (item.data && item.data.baseData) { + Assert.equal(item.data.baseData.name, "normal-span", "Only normal span should be sent"); + } + } + }); + + this.testCase({ + name: "TelemetryInitializer: can enrich with context data", + useFakeTimers: true, + test: () => { + this._ai.addTelemetryInitializer((item: ITelemetryItem) => { + // Add environment and build info to all span telemetry + if (item.baseType === "RemoteDependencyData") { + item.baseData = item.baseData || {}; + item.baseData.properties = item.baseData.properties || {}; + item.baseData.properties["environment"] = "test"; + item.baseData.properties["build.version"] = "1.2.3"; + item.baseData.properties["region"] = "us-west"; + } + return true; + }); + + const span = this._ai.startSpan("enriched-span", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + const item = sentItems[0] as ITelemetryItem; + if (!item.data) { + return; + } + if (!item.data.baseData) { + return; + } + + Assert.equal(item.data.baseData.properties["environment"], "test", "Environment should be added"); + Assert.equal(item.data.baseData.properties["build.version"], "1.2.3", "Build version should be added"); + Assert.equal(item.data.baseData.properties["region"], "us-west", "Region should be added"); + } + }); + } + + private addSessionContextTests(): void { + this.testCase({ + name: "SessionContext: consistent session ID across multiple spans", + useFakeTimers: true, + test: () => { + const span1 = this._ai.startSpan("operation-1", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span1, "First span should be created"); + if (span1) { + span1.end(); + } + + const span2 = this._ai.startSpan("operation-2", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span2, "Second span should be created"); + if (span2) { + span2.end(); + } + + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(sentItems.length, 2, "Two telemetry items should be sent"); + + const payload1 = sentItems[0]; + const payload2 = sentItems[1]; + + const sessionId1 = payload1.tags ? payload1.tags["ai.session.id"] : undefined; + const sessionId2 = payload2.tags ? payload2.tags["ai.session.id"] : undefined; + + Assert.ok(sessionId1, "First item should have session ID"); + Assert.ok(sessionId2, "Second item should have session ID"); + Assert.equal(sessionId1, sessionId2, "Session IDs should be consistent"); + } + }); + + this.testCase({ + name: "SessionContext: session renewal doesn't affect active spans", + useFakeTimers: true, + test: () => { + const span1 = this._ai.startSpan("before-renewal", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span1, "Span before renewal should be created"); + if (span1) { + span1.end(); + } + this.clock.tick(500); + + // Simulate session renewal time passing (30+ minutes) + this.clock.tick(31 * 60 * 1000); + + const span2 = this._ai.startSpan("after-renewal", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span2, "Span after renewal should be created"); + if (span2) { + span2.end(); + } + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(sentItems.length, 2, "Both spans should be sent"); + + // Session might have renewed, but both spans should have valid session IDs + const payload1 = sentItems[0]; + const payload2 = sentItems[1]; + + const sessionId1 = payload1.tags ? payload1.tags["ai.session.id"] : undefined; + const sessionId2 = payload2.tags ? payload2.tags["ai.session.id"] : undefined; + + Assert.ok(sessionId1, "First span should have session ID"); + Assert.ok(sessionId2, "Second span should have session ID"); + } + }); + } + + private addUserContextTests(): void { + this.testCase({ + name: "UserContext: setting user ID applies to subsequent spans", + useFakeTimers: true, + test: () => { + // Set user context + this._ai.context.user.id = "user-12345"; + this._ai.context.user.authenticatedId = "auth-user-67890"; + + const span = this._ai.startSpan("user-operation", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + const payload = sentItems[0]; + + Assert.ok(payload.tags, "Payload should have tags"); + + // User ID is sent in tags with key "ai.user.id" + const userId = payload.tags["ai.user.id"]; + Assert.equal(userId, "user-12345", "User ID should match"); + + // Auth user ID is sent in tags with key "ai.user.authUserId" + const authUserId = payload.tags["ai.user.authUserId"]; + Assert.equal(authUserId, "auth-user-67890", "Authenticated ID should match"); + } + }); + + this.testCase({ + name: "UserContext: clearing user context removes from spans", + useFakeTimers: true, + test: () => { + // Set then clear + this._ai.context.user.authenticatedId = "temp-user"; + this._ai.context.user.clearAuthenticatedUserContext(); + + const span = this._ai.startSpan("after-clear", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + const payload = sentItems[0]; + + // User context should still exist but authenticated ID should be undefined/missing + if (payload.tags) { + const authUserId = payload.tags["ai.user.authUserId"]; + Assert.ok(!authUserId || authUserId === undefined, + "Authenticated ID should be cleared"); + } + } + }); + } + + private addDeviceContextTests(): void { + this.testCase({ + name: "DeviceContext: device information included in all spans", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("device-check", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + const payload = sentItems[0]; + + Assert.ok(payload.tags, "Payload should have tags"); + + // Device info is sent in tags + const deviceType = payload.tags["ai.device.type"]; + const deviceId = payload.tags["ai.device.id"]; + + Assert.ok(deviceType, "Device type should be set"); + Assert.ok(deviceId, "Device ID should be set"); + } + }); + } + + private addDistributedTraceContextTests(): void { + this.testCase({ + name: "DistributedTrace: parent-child spans share trace ID", + useFakeTimers: true, + test: () => { + const parentSpan = this._ai.startSpan("parent-op", { + kind: eOTelSpanKind.SERVER + }); + Assert.ok(parentSpan, "Parent span should be created"); + if (!parentSpan) { + return; + } + + const childSpan = this._ai.startSpan("child-op", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(childSpan, "Child span should be created"); + + parentSpan.end(); + if (childSpan) { + childSpan.end(); + } + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(sentItems.length, 2, "Both spans should be sent"); + + const parentPayload = sentItems[0]; + const childPayload = sentItems[1]; + + // Both should have same operation ID (trace ID) in tags + const parentOpId = parentPayload.tags ? parentPayload.tags["ai.operation.id"] : undefined; + const childOpId = childPayload.tags ? childPayload.tags["ai.operation.id"] : undefined; + + Assert.ok(parentOpId, "Parent should have operation ID"); + Assert.ok(childOpId, "Child should have operation ID"); + Assert.equal(parentOpId, childOpId, "Trace IDs should match for parent and child"); + } + }); + + this.testCase({ + name: "DistributedTrace: span context propagates through telemetry", + useFakeTimers: true, + test: () => { + const span = this._ai.startSpan("traced-operation", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + + const spanContext = span.spanContext(); + Assert.ok(spanContext.traceId, "Span should have trace ID"); + Assert.ok(spanContext.spanId, "Span should have span ID"); + + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + const payload = sentItems[0]; + + // Trace context should be in tags + if (payload.tags) { + const operationId = payload.tags["ai.operation.id"]; + const operationParentId = payload.tags["ai.operation.parentId"]; + + Assert.equal(operationId, spanContext.traceId, "Operation ID should match trace ID"); + Assert.ok(operationParentId, "Operation parent ID should be set"); + } + } + }); + } + + private addSamplingIntegrationTests(): void { + this.testCase({ + name: "Sampling: 1% sampling allows minimal span telemetry", + useFakeTimers: true, + test: () => { + // Recreate AI with 1% sampling (minimum valid value) + this._ai.unload(false); + + this._ai = new ApplicationInsights({ + config: { + instrumentationKey: "test-ikey-123", + samplingPercentage: 1 + } + }); + this._ai.loadAppInsights(); + + const span = this._ai.startSpan("low-sampled", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should still be created"); + if (span) { + span.end(); + } + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + // With 1% sampling, telemetry may or may not be sent (depends on sample hash) + Assert.ok(sentItems.length >= 0, "Telemetry should respect 1% sampling rate"); + } + }); + + this.testCase({ + name: "Sampling: 100% sampling sends all span telemetry", + useFakeTimers: true, + test: () => { + // Default config has 100% sampling + const spans = []; + for (let i = 0; i < 10; i++) { + const span = this._ai.startSpan("operation-" + i, { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span " + i + " should be created"); + if (span) { + span.end(); + spans.push(span); + } + } + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.equal(sentItems.length, 10, "All 10 spans should be sent with 100% sampling"); + } + }); + } + + private addConfigurationIntegrationTests(): void { + this.testCase({ + name: "Config: disableAjaxTracking doesn't affect manual spans", + useFakeTimers: true, + test: () => { + // Config already has disableAjaxTracking: false, but manual spans should work regardless + const span = this._ai.startSpan("manual-span", { + kind: eOTelSpanKind.CLIENT + }); + Assert.ok(span, "Manual span should be created"); + if (!span) { + return; + } + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Manual span should be sent regardless of ajax tracking config"); + } + }); + + this.testCase({ + name: "Config: maxBatchInterval affects when span telemetry is sent", + useFakeTimers: true, + test: () => { + // Current config has maxBatchInterval: 0 (send immediately) + const span = this._ai.startSpan("immediate-send", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + span.end(); + + // No tick needed with maxBatchInterval: 0 + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + Assert.ok(sentItems.length > 0, "Span should be sent immediately"); + } + }); + + this.testCase({ + name: "Config: extensionConfig reaches PropertiesPlugin", + useFakeTimers: true, + test: () => { + // We set accountId in extensionConfig during init + const span = this._ai.startSpan("config-test", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span, "Span should be created"); + if (!span) { + return; + } + span.end(); + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + const payload = sentItems[0]; + + // Check if account ID from config made it through tags + if (payload.tags) { + const accountId = payload.tags["ai.user.accountId"]; + if (accountId) { + Assert.equal(accountId, "test-account-id", + "Account ID from config should be present in tags"); + } + } + } + }); + + this.testCase({ + name: "Config: dynamic configuration changes affect new spans", + useFakeTimers: true, + test: () => { + // Create span with initial config + const span1 = this._ai.startSpan("before-config-change", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span1, "First span should be created"); + if (span1) { + span1.end(); + } + this.clock.tick(500); + + // Change configuration dynamically + this._ai.config.extensionConfig = this._ai.config.extensionConfig || {}; + this._ai.config.extensionConfig["AppInsightsChannelPlugin"] = + this._ai.config.extensionConfig["AppInsightsChannelPlugin"] || {}; + this._ai.config.extensionConfig["AppInsightsChannelPlugin"].samplingPercentage = 1; + this.clock.tick(500); // Allow config change to propagate + + // Create span after config change + const span2 = this._ai.startSpan("after-config-change", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(span2, "Second span should be created"); + if (span2) { + span2.end(); + } + this.clock.tick(500); + + const sentItems = this.getSentTelemetry(); + + // First span should be sent (100% default), second may be sampled (1%) + Assert.ok(sentItems.length >= 1, "At least first span should be sent"); + + const firstItem = sentItems[0] as ITelemetryItem; + if (firstItem.data && firstItem.data.baseData) { + Assert.equal(firstItem.data.baseData.name, "before-config-change", + "First span should be sent before config change"); + } + } + }); + } + + // Helper methods + private getSentTelemetry(): any[] { + const items: any[] = []; + const requests = this.activeXhrRequests; + if (requests) { + requests.forEach((request: any) => { + if (request.requestBody) { + try { + const payload = JSON.parse(request.requestBody); + if (payload && Array.isArray(payload)) { + items.push(...payload); + } else if (payload) { + items.push(payload); + } + } catch (e) { + // Ignore parse errors + } + } + }); + } + return items; + } + + private parseDurationToMs(duration: string): number { + // Duration format: "00:00:00.250" or similar + if (!duration) { + return 0; + } + + const parts = duration.split(":"); + if (parts.length !== 3) { + return 0; + } + + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const secondsParts = parts[2].split("."); + const seconds = parseInt(secondsParts[0], 10); + const milliseconds = secondsParts[1] ? parseInt(secondsParts[1].padEnd(3, "0").substring(0, 3), 10) : 0; + + return (hours * 3600000) + (minutes * 60000) + (seconds * 1000) + milliseconds; + } +} diff --git a/AISKU/Tests/Unit/src/SpanUtils.Tests.ts b/AISKU/Tests/Unit/src/SpanUtils.Tests.ts new file mode 100644 index 000000000..6b6346b0e --- /dev/null +++ b/AISKU/Tests/Unit/src/SpanUtils.Tests.ts @@ -0,0 +1,1971 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights, IDependencyTelemetry } from "../../../src/applicationinsights-web"; +import { + eOTelSpanKind, + eOTelSpanStatusCode, + ITelemetryItem, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_URL, + SEMATTRS_HTTP_STATUS_CODE, + SEMATTRS_DB_SYSTEM, + SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_NAME, + SEMATTRS_RPC_SYSTEM, + SEMATTRS_RPC_GRPC_STATUS_CODE, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_URL_FULL, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_ENDUSER_ID, + ATTR_ENDUSER_PSEUDO_ID, + ATTR_HTTP_ROUTE, + MicrosoftClientIp +} from "@microsoft/applicationinsights-core-js"; +import { IRequestTelemetry } from "@microsoft/applicationinsights-common"; + +export class SpanUtilsTests extends AITestClass { + private static readonly _instrumentationKey = "b7170927-2d1c-44f1-acec-59f4e1751c11"; + private static readonly _connectionString = `InstrumentationKey=${SpanUtilsTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "SpanUtilsTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: SpanUtilsTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + } catch (e) { + console.error("Failed to initialize tests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addDependencyTelemetryTests(); + this.addRequestTelemetryTests(); + this.addHttpDependencyTests(); + this.addDbDependencyTests(); + this.addRpcDependencyTests(); + this.addAttributeMappingTests(); + this.addTagsCreationTests(); + this.addAzureSDKTests(); + this.addSemanticAttributeExclusionTests(); + this.addEdgeCaseTests(); + this.addCrossBrowserCompatibilityTests(); + } + + private addDependencyTelemetryTests(): void { + this.testCase({ + name: "createDependencyTelemetry: CLIENT span generates RemoteDependency telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("client-operation", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "custom.attr": "value" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.equal(item.name, "Microsoft.ApplicationInsights.RemoteDependency", "Should be RemoteDependency"); + Assert.equal(item.baseType, "RemoteDependencyData", "Should have correct baseType"); + Assert.ok(item.baseData, "Should have baseData"); + Assert.equal((item.baseData as any).name, "client-operation", "Should have span name"); + Assert.equal((item.baseData as any).type, "Dependency", "Should have default dependency type"); + } + }); + + this.testCase({ + name: "createDependencyTelemetry: PRODUCER span generates QueueMessage dependency", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("queue-producer", { + kind: eOTelSpanKind.PRODUCER, + attributes: { + "messaging.system": "kafka", + "messaging.destination": "orders" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "Queue Message", "Should be QueueMessage type"); + } + }); + + this.testCase({ + name: "createDependencyTelemetry: INTERNAL span with parent generates InProc dependency", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const parentSpan = this._ai.startSpan("parent-operation"); + const parentContext = parentSpan?.spanContext(); + + const childSpan = this._ai.startSpan("internal-operation", { + kind: eOTelSpanKind.INTERNAL + }, parentContext); + childSpan?.end(); + parentSpan?.end(); + + // Assert + const childItem = this._trackCalls.find(t => t.baseData?.name === "internal-operation"); + Assert.ok(childItem, "Should have child telemetry"); + Assert.equal((childItem?.baseData as any).type, "InProc", "Should be InProc type"); + } + }); + + this.testCase({ + name: "createDependencyTelemetry: SUCCESS status based on span status code", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - span with OK status + const okSpan = this._ai.startSpan("ok-span", { kind: eOTelSpanKind.CLIENT }); + okSpan?.setStatus({ code: eOTelSpanStatusCode.OK }); + okSpan?.end(); + + // Act - span with ERROR status + const errorSpan = this._ai.startSpan("error-span", { kind: eOTelSpanKind.CLIENT }); + errorSpan?.setStatus({ code: eOTelSpanStatusCode.ERROR, message: "Failed" }); + errorSpan?.end(); + + // Assert + const okItem = this._trackCalls.find(t => t.baseData?.name === "ok-span"); + const errorItem = this._trackCalls.find(t => t.baseData?.name === "error-span"); + + Assert.equal((okItem?.baseData as any).success, true, "OK span should have success=true"); + Assert.equal((errorItem?.baseData as any).success, false, "ERROR span should have success=false"); + } + }); + + this.testCase({ + name: "createDependencyTelemetry: includes span context IDs", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("test-span", { kind: eOTelSpanKind.CLIENT }); + const spanContext = span?.spanContext(); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).id, spanContext?.spanId, "Should have spanId"); + Assert.ok(item.tags, "Should have tags"); + Assert.equal((item.tags as any)["ai.operation.id"], spanContext?.traceId, "Should have traceId in tags"); + } + }); + } + + private addRequestTelemetryTests(): void { + this.testCase({ + name: "createRequestTelemetry: SERVER span generates Request telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("server-operation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "GET", + "http.url": "https://example.com/api/users" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.equal(item.name, "Microsoft.ApplicationInsights.Request", "Should be Request"); + Assert.equal(item.baseType, "RequestData", "Should have correct baseType"); + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "createRequestTelemetry: CONSUMER span generates Request telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("queue-consumer", { + kind: eOTelSpanKind.CONSUMER, + attributes: { + "messaging.system": "rabbitmq" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal(item.name, "Microsoft.ApplicationInsights.Request", "Should be Request"); + } + }); + + this.testCase({ + name: "createRequestTelemetry: SUCCESS derived from status code", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - UNSET status with 2xx HTTP code + const successSpan = this._ai.startSpan("success-request", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "POST", + "http.status_code": 201 + } + }); + successSpan?.end(); + + // Act - UNSET status with 5xx HTTP code + const failSpan = this._ai.startSpan("fail-request", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "GET", + "http.status_code": 500 + } + }); + failSpan?.end(); + + // Assert + const successItem = this._trackCalls.find(t => t.baseData?.name === "success-request"); + const failItem = this._trackCalls.find(t => t.baseData?.name === "fail-request"); + + Assert.equal((successItem?.baseData as any).success, true, "2xx should be success"); + Assert.equal((failItem?.baseData as any).success, false, "5xx should be failure"); + } + }); + + this.testCase({ + name: "createRequestTelemetry: OK status overrides HTTP status code", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - OK status with 5xx code (shouldn't happen but testing precedence) + const span = this._ai.startSpan("explicit-ok", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "GET", + "http.status_code": 500 + } + }); + span?.setStatus({ code: eOTelSpanStatusCode.OK }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).success, true, "OK status should take precedence"); + } + }); + + this.testCase({ + name: "createRequestTelemetry: includes URL for HTTP requests", + test: () => { + // Arrange + this._trackCalls = []; + const testUrl = "https://api.example.com/v1/users?id=123"; + + // Act + const span = this._ai.startSpan("http-request", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "GET", + "http.url": testUrl, + "http.status_code": 200 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const baseData = item.baseData as IRequestTelemetry; + Assert.equal(baseData.url, testUrl, "Should include URL"); + Assert.equal(baseData.responseCode, 200, "Should include status code"); + } + }); + + this.testCase({ + name: "createRequestTelemetry: gRPC status code mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("grpc-request", { + kind: eOTelSpanKind.SERVER, + attributes: { + "rpc.system": "grpc", + "rpc.grpc.status_code": 0 // OK + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const baseData = item.baseData as IRequestTelemetry; + Assert.equal(baseData.responseCode, 0, "Should map gRPC status code"); + } + }); + } + + private addHttpDependencyTests(): void { + this.testCase({ + name: "HTTP Dependency: legacy semantic conventions mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("http-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_HTTP_METHOD]: "POST", + [SEMATTRS_HTTP_URL]: "https://api.example.com/v1/users", + [SEMATTRS_HTTP_STATUS_CODE]: 201 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const baseData = item.baseData as IDependencyTelemetry; + Assert.equal(baseData.type, "Http", "Should be HTTP type"); + Assert.ok(baseData.name?.startsWith("POST"), "Name should include method"); + Assert.equal(baseData.data, "https://api.example.com/v1/users", "Should include URL"); + Assert.equal(baseData.responseCode, 201, "Should include status code"); + } + }); + + this.testCase({ + name: "HTTP Dependency: new semantic conventions mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("http-call-new", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [ATTR_HTTP_REQUEST_METHOD]: "GET", + [ATTR_URL_FULL]: "https://api.example.com/v2/products", + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_SERVER_ADDRESS]: "api.example.com", + [ATTR_SERVER_PORT]: 443 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const baseData = item.baseData as IDependencyTelemetry; + Assert.equal(baseData.type, "Http", "Should be HTTP type"); + Assert.ok(baseData.data, "Should have data field"); + } + }); + + this.testCase({ + name: "HTTP Dependency: target with default port removal", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - HTTPS with default port 443 + const httpsSpan = this._ai.startSpan("https-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "GET", + "http.url": "https://example.com:443/api", + "net.peer.name": "example.com", + "net.peer.port": 443 + } + }); + httpsSpan?.end(); + + // Act - HTTP with default port 80 + const httpSpan = this._ai.startSpan("http-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "GET", + "http.url": "http://example.com:80/api", + "net.peer.name": "example.com", + "net.peer.port": 80 + } + }); + httpSpan?.end(); + + // Assert + const httpsItem = this._trackCalls.find(t => t.baseData?.name?.includes("https-call") || t.baseData?.data?.includes("https://example.com:443")); + const httpItem = this._trackCalls.find(t => t.baseData?.name?.includes("http-call") || t.baseData?.data?.includes("http://example.com:80")); + + // Default ports should be stripped from target + Assert.ok(httpsItem, "Should have HTTPS telemetry"); + Assert.ok(httpItem, "Should have HTTP telemetry"); + } + }); + + this.testCase({ + name: "HTTP Dependency: target with non-default port preserved", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("custom-port-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "GET", + "http.url": "https://example.com:8443/api", + "net.peer.name": "example.com", + "net.peer.port": 8443 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).target, "Should have target"); + // Non-default port should be preserved in target + } + }); + + this.testCase({ + name: "HTTP Dependency: name generated from URL pathname", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("generic-name", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "DELETE", + "http.url": "https://api.example.com/v1/users/123" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).name.includes("DELETE"), "Name should include HTTP method"); + Assert.ok((item.baseData as any).name.includes("/v1/users/123"), "Name should include path"); + } + }); + } + + private addDbDependencyTests(): void { + this.testCase({ + name: "DB Dependency: MySQL mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("db-query", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "mysql", + [SEMATTRS_DB_STATEMENT]: "SELECT * FROM users WHERE id = ?", + [SEMATTRS_DB_NAME]: "myapp_db", + "net.peer.name": "db.example.com", + "net.peer.port": 3306 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "mysql", "Should be mysql type"); + Assert.equal((item.baseData as any).data, "SELECT * FROM users WHERE id = ?", "Should include statement"); + Assert.ok((item.baseData as any).target?.includes("myapp_db"), "Target should include DB name"); + } + }); + + this.testCase({ + name: "DB Dependency: PostgreSQL mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("postgres-query", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "postgresql", + [SEMATTRS_DB_STATEMENT]: "INSERT INTO logs (message) VALUES ($1)" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "postgresql", "Should be postgresql type"); + } + }); + + this.testCase({ + name: "DB Dependency: MongoDB mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("mongo-query", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "mongodb", + [SEMATTRS_DB_STATEMENT]: "db.users.find({age: {$gt: 25}})" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "mongodb", "Should be mongodb type"); + } + }); + + this.testCase({ + name: "DB Dependency: Redis mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("redis-cmd", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "redis", + [SEMATTRS_DB_STATEMENT]: "GET user:123" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "redis", "Should be redis type"); + } + }); + + this.testCase({ + name: "DB Dependency: SQL Server mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("mssql-query", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "mssql", + [SEMATTRS_DB_STATEMENT]: "SELECT TOP 10 * FROM Orders" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "SQL", "Should be SQL type for SQL Server"); + } + }); + + this.testCase({ + name: "DB Dependency: operation used when no statement", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("db-op", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "postgresql", + "db.operation": "SELECT", + [SEMATTRS_DB_NAME]: "products_db" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).data, "SELECT", "Should use operation when no statement"); + } + }); + + this.testCase({ + name: "DB Dependency: target formatting with host and dbname", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("db-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "mysql", + [SEMATTRS_DB_NAME]: "production_db", + "net.peer.name": "mysql-prod.example.com", + "net.peer.port": 3306 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).target?.includes("mysql-prod.example.com"), "Target should include host"); + Assert.ok((item.baseData as any).target?.includes("production_db"), "Target should include DB name"); + } + }); + } + + private addRpcDependencyTests(): void { + this.testCase({ + name: "RPC Dependency: gRPC mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("grpc-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_RPC_SYSTEM]: "grpc", + [SEMATTRS_RPC_GRPC_STATUS_CODE]: 0, + "rpc.service": "UserService", + "rpc.method": "GetUser" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + let baseData = item.baseData as IDependencyTelemetry; + Assert.equal(baseData.type, "GRPC", "Should be Dependency type"); + Assert.equal(baseData.responseCode, 0, "Should include gRPC status code"); + } + }); + + this.testCase({ + name: "RPC Dependency: WCF mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("wcf-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_RPC_SYSTEM]: "wcf", + "rpc.service": "CalculatorService" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "WCF Service", "Should be Dependency type"); + } + }); + + this.testCase({ + name: "RPC Dependency: target from peer service", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("rpc-call", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_RPC_SYSTEM]: "grpc", + "net.peer.name": "grpc.example.com", + "net.peer.port": 50051 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + let baseData = item.baseData as IDependencyTelemetry; + Assert.ok(baseData.target, "Should have target"); + } + }); + } + + private addAttributeMappingTests(): void { + this.testCase({ + name: "Attribute Mapping: custom attributes preserved in properties", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("custom-attrs", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "app.version": "1.2.3", + "user.tier": "premium", + "request.priority": 5, + "feature.enabled": true + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).properties, "Should have properties"); + Assert.equal((item.baseData as any).properties["app.version"], "1.2.3", "String attribute preserved"); + Assert.equal((item.baseData as any).properties["user.tier"], "premium", "String attribute preserved"); + Assert.equal((item.baseData as any).properties["request.priority"], 5, "Number attribute preserved"); + Assert.equal((item.baseData as any).properties["feature.enabled"], true, "Boolean attribute preserved"); + } + }); + + this.testCase({ + name: "Attribute Mapping: dt.spanId and dt.traceId always added", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("test-span", { kind: eOTelSpanKind.CLIENT }); + const context = span?.spanContext(); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal(item.ext?.dt.spanId, context?.spanId, "Should have dt.spanId"); + Assert.equal(item.ext?.dt.traceId, context?.traceId, "Should have dt.traceId"); + } + }); + + this.testCase({ + name: "Attribute Mapping: sampling.probability mapped to sampleRate", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("sampled-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "microsoft.sample_rate": 25 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0] as any; + Assert.equal(item.sampleRate, 25, "Should map sampling.probability to sampleRate"); + } + }); + } + + private addTagsCreationTests(): void { + this.testCase({ + name: "Tags: operation ID from trace ID", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("test-span", { kind: eOTelSpanKind.SERVER }); + const context = span?.spanContext(); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.tags as any)["ai.operation.id"], context?.traceId, "Should map traceId to operation.id"); + } + }); + + this.testCase({ + name: "Tags: operation parent ID from parent span", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const parentSpan = this._ai.startSpan("parent", { kind: eOTelSpanKind.SERVER }); + const parentContext = parentSpan?.spanContext(); + + const childSpan = this._ai.startSpan("child", { kind: eOTelSpanKind.INTERNAL }, parentContext); + childSpan?.end(); + parentSpan?.end(); + + // Assert + const childItem = this._trackCalls.find(t => t.baseData?.name === "child"); + Assert.equal((childItem?.tags as any)?.["ai.operation.parentId"], parentContext?.spanId, + "Should map parent spanId to operation.parentId"); + } + }); + + this.testCase({ + name: "Tags: enduser.id mapped to user auth ID", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("user-span", { + kind: eOTelSpanKind.SERVER, + attributes: { + [ATTR_ENDUSER_ID]: "user@example.com" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.tags as any)["ai.user.authUserId"], "user@example.com", + "Should map enduser.id to user.authUserId"); + } + }); + + this.testCase({ + name: "Tags: enduser.pseudo.id mapped to user ID", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("pseudo-user-span", { + kind: eOTelSpanKind.SERVER, + attributes: { + [ATTR_ENDUSER_PSEUDO_ID]: "anon-12345" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.tags as any)["ai.user.id"], "anon-12345", + "Should map enduser.pseudo.id to user.id"); + } + }); + + this.testCase({ + name: "Tags: microsoft.client.ip takes precedence", + test: () => { + // Arrange + this._trackCalls = []; + const clientIp = "203.0.113.42"; + + // Act + const span = this._ai.startSpan("ip-span", { + kind: eOTelSpanKind.SERVER, + attributes: { + [MicrosoftClientIp]: clientIp, + "client.address": "192.168.1.1", // Should be ignored + "http.client_ip": "10.0.0.1" // Should be ignored + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.tags as any)["ai.location.ip"], clientIp, + "microsoft.client.ip should take precedence"); + } + }); + + this.testCase({ + name: "Tags: operation name from http.route for SERVER spans", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("http-request", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "POST", + [ATTR_HTTP_ROUTE]: "/api/v1/users/:id", + "http.url": "https://example.com/api/v1/users/123" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.tags as any)["ai.operation.name"]?.includes("POST"), "Should include method"); + Assert.ok((item.tags as any)["ai.operation.name"]?.includes("/api/v1/users/:id"), "Should include route"); + } + }); + + this.testCase({ + name: "Tags: operation name falls back to URL path when no route", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("http-request-no-route", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "GET", + "http.url": "https://example.com/products/search?q=laptop" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.tags as any)["ai.operation.name"]?.includes("GET"), "Should include method"); + Assert.ok((item.tags as any)["ai.operation.name"]?.includes("/products/search"), "Should include path"); + } + }); + + this.testCase({ + name: "Tags: user agent mapped correctly", + test: () => { + // Arrange + this._trackCalls = []; + const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0"; + + // Act + const span = this._ai.startSpan("ua-span", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.user_agent": userAgent + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.tags as any)["ai.user.userAgent"], userAgent, "Should map user agent"); + } + }); + + this.testCase({ + name: "Tags: synthetic source detection", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("bot-span", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.user_agent": "Googlebot/2.1 (+http://www.google.com/bot.html)" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + // Synthetic source should be detected for bot user agents + if ((item.tags as any)["ai.operation.syntheticSource"]) { + Assert.equal((item.tags as any)["ai.operation.syntheticSource"], "True", + "Should detect synthetic source for bots"); + } + } + }); + } + + private addAzureSDKTests(): void { + this.testCase({ + name: "Azure SDK: EventHub PRODUCER span mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("EventHubs.send", { + kind: eOTelSpanKind.PRODUCER, + attributes: { + "az.namespace": "Microsoft.EventHub", + "message_bus.destination": "telemetry-events", + "net.peer.name": "eventhub.servicebus.windows.net" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).type?.includes("Queue Message"), "Should be Queue Message type"); + Assert.ok((item.baseData as any).type?.includes("Microsoft.EventHub"), "Should include namespace"); + Assert.ok((item.baseData as any).target, "Should have target"); + } + }); + + this.testCase({ + name: "Azure SDK: EventHub CONSUMER span mapping", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("EventHubs.process", { + kind: eOTelSpanKind.CONSUMER, + attributes: { + "az.namespace": "Microsoft.EventHub", + "message_bus.destination": "telemetry-events", + "net.peer.name": "eventhub.servicebus.windows.net", + "enqueuedTime": "1638360000000" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).source, "Consumer should have source"); + Assert.ok((item.baseData as any).measurements, "Should have measurements"); + Assert.ok("timeSinceEnqueued" in (item.baseData as any).measurements, "Should have timeSinceEnqueued measurement"); + } + }); + + this.testCase({ + name: "Azure SDK: INTERNAL span with Azure namespace", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("internal-azure-op", { + kind: eOTelSpanKind.INTERNAL, + attributes: { + "az.namespace": "Microsoft.Storage" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).type?.includes("InProc"), "Should include InProc"); + Assert.ok((item.baseData as any).type?.includes("Microsoft.Storage"), "Should include namespace"); + } + }); + } + + private addSemanticAttributeExclusionTests(): void { + this.testCase({ + name: "Semantic Exclusion: HTTP attributes not in properties", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("http-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://example.com/api", + "http.status_code": 201, + "http.user_agent": "TestAgent/1.0", + "custom.attribute": "should-be-kept" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + + Assert.ok(!props["http.method"], "http.method should be excluded"); + Assert.ok(!props["http.url"], "http.url should be excluded"); + Assert.ok(!props["http.status_code"], "http.status_code should be excluded"); + Assert.ok(!props["http.user_agent"], "http.user_agent should be excluded"); + Assert.equal(props["custom.attribute"], "should-be-kept", "Custom attributes should be kept"); + } + }); + + this.testCase({ + name: "Semantic Exclusion: DB attributes not in properties", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("db-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "db.system": "postgresql", + "db.statement": "SELECT * FROM users", + "db.name": "mydb", + "db.operation": "SELECT", + "app.query.id": "query-123" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + + Assert.ok(!props["db.system"], "db.system should be excluded"); + Assert.ok(!props["db.statement"], "db.statement should be excluded"); + Assert.ok(!props["db.name"], "db.name should be excluded"); + Assert.ok(!props["db.operation"], "db.operation should be excluded"); + Assert.equal(props["app.query.id"], "query-123", "Custom attributes should be kept"); + } + }); + + this.testCase({ + name: "Semantic Exclusion: microsoft.* attributes excluded", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("microsoft-attrs", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "microsoft.internal.flag": true, + "microsoft.client.ip": "192.168.1.1", + "microsoft.custom": "value", + "app.microsoft": "not-excluded" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + + Assert.ok(!props["microsoft.internal.flag"], "microsoft.* should be excluded"); + Assert.ok(!props["microsoft.client.ip"], "microsoft.* should be excluded"); + Assert.ok(!props["microsoft.custom"], "microsoft.* should be excluded"); + Assert.equal(props["app.microsoft"], "not-excluded", + "Attributes containing 'microsoft' but not prefixed should be kept"); + } + }); + + this.testCase({ + name: "Semantic Exclusion: operation.name context tag excluded", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("op-name-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "ai.operation.name": "CustomOperation", + "custom.operation.name": "should-be-kept" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + + Assert.ok(!props["ai.operation.name"], "ai.operation.name should be excluded"); + Assert.equal(props["custom.operation.name"], "should-be-kept", + "Similar named custom attributes should be kept"); + } + }); + + this.testCase({ + name: "Semantic Exclusion: new semantic conventions excluded", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("new-semconv", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [ATTR_HTTP_REQUEST_METHOD]: "GET", + [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, + [ATTR_URL_FULL]: "https://example.com", + [ATTR_SERVER_ADDRESS]: "example.com", + [ATTR_SERVER_PORT]: 443, + "app.request.id": "req-123" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + + Assert.ok(!props["http.request.method"], "New http attributes should be excluded"); + Assert.ok(!props["http.response.status_code"], "New http attributes should be excluded"); + Assert.ok(!props["url.full"], "New url attributes should be excluded"); + Assert.ok(!props["server.address"], "New server attributes should be excluded"); + Assert.ok(!props["server.port"], "New server attributes should be excluded"); + Assert.equal(props["app.request.id"], "req-123", "Custom attributes should be kept"); + } + }); + } + + private addEdgeCaseTests(): void { + this.testCase({ + name: "Edge Case: Empty span name", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("", { + kind: eOTelSpanKind.CLIENT + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry for empty name"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + Assert.equal((item.baseData as any).name, "", "Should preserve empty name"); + } + }); + + this.testCase({ + name: "Edge Case: Span with null/undefined attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("null-attrs", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "valid.attr": "value", + "null.attr": null as any, + "undefined.attr": undefined as any, + "zero.attr": 0, + "false.attr": false, + "empty.string": "" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.equal(props["valid.attr"], "value", "Valid attributes should be preserved"); + Assert.equal(props["zero.attr"], 0, "Zero values should be preserved"); + Assert.equal(props["false.attr"], false, "False values should be preserved"); + Assert.equal(props["empty.string"], "", "Empty strings should be preserved"); + } + }); + + this.testCase({ + name: "Edge Case: Span with extremely long attribute values", + test: () => { + // Arrange + this._trackCalls = []; + const veryLongValue = "a".repeat(20000); + + // Act + const span = this._ai.startSpan("long-attrs", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "long.value": veryLongValue + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.ok(props["long.value"], "Long value should be included"); + Assert.equal(props["long.value"], veryLongValue, "Long value should be preserved"); + } + }); + + this.testCase({ + name: "Edge Case: Span with special characters in name and attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("span-with-特殊字符-émojis-🎉", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "unicode.key": "value with 中文 and émojis 🚀", + "special.chars": "tab\there\nnewline\r\ncarriage", + "quotes": "\"double\" and 'single' quotes" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle special characters"); + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).name.includes("特殊字符"), "Should preserve unicode in name"); + const props = (item.baseData as any).properties || {}; + Assert.ok(props["unicode.key"], "Should preserve unicode attributes"); + Assert.ok(props["special.chars"], "Should preserve special characters"); + Assert.ok(props["quotes"], "Should preserve quotes"); + } + }); + + this.testCase({ + name: "Edge Case: Span without explicit kind defaults appropriately", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - startSpan with no kind specified + const span = this._ai.startSpan("no-kind-span"); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item, "Should have telemetry item"); + } + }); + + this.testCase({ + name: "Edge Case: Multiple rapid span creations and endings", + test: () => { + // Arrange + this._trackCalls = []; + const spanCount = 50; + + // Act - Create and end many spans rapidly + for (let i = 0; i < spanCount; i++) { + const span = this._ai.startSpan("rapid-span-" + i, { + kind: eOTelSpanKind.CLIENT, + attributes: { + "span.index": i + } + }); + span?.end(); + } + + // Assert + Assert.equal(this._trackCalls.length, spanCount, "Should track all spans"); + const firstItem = this._trackCalls[0]; + const lastItem = this._trackCalls[spanCount - 1]; + Assert.equal((firstItem.baseData as any).properties["span.index"], 0, "First span preserved"); + Assert.equal((lastItem.baseData as any).properties["span.index"], spanCount - 1, "Last span preserved"); + } + }); + + this.testCase({ + name: "Edge Case: Span with array attribute values", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("array-attrs", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "string.array": ["value1", "value2", "value3"], + "number.array": [1, 2, 3], + "mixed.array": ["string", 123, true] as any + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.ok(props["string.array"], "Array attributes should be included"); + } + }); + + this.testCase({ + name: "Edge Case: Span with nested object attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("nested-attrs", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "nested.object": { key: "value", nested: { deep: "data" } } as any, + "simple.attr": "simple" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.ok(props["simple.attr"], "Simple attributes should work"); + } + }); + + this.testCase({ + name: "Edge Case: Span with malformed HTTP status codes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("malformed-status", { + kind: eOTelSpanKind.SERVER, + attributes: { + [SEMATTRS_HTTP_STATUS_CODE]: "not-a-number" as any + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle malformed status codes"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "Edge Case: Span with missing parent context", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - Explicitly pass null/undefined parent context + const span = this._ai.startSpan("orphan-span", { + kind: eOTelSpanKind.CLIENT + }, undefined); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle missing parent"); + const item = this._trackCalls[0]; + Assert.ok((item.tags as any)["ai.operation.id"], "Should have operation ID"); + } + }); + + this.testCase({ + name: "Edge Case: Span ended multiple times", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("multi-end", { + kind: eOTelSpanKind.CLIENT + }); + span?.end(); + const firstCallCount = this._trackCalls.length; + span?.end(); // End again + const secondCallCount = this._trackCalls.length; + + // Assert + Assert.equal(firstCallCount, 1, "First end should generate telemetry"); + Assert.equal(secondCallCount, 1, "Second end should not generate duplicate telemetry"); + } + }); + + this.testCase({ + name: "Edge Case: Span with extremely large number of attributes", + test: () => { + // Arrange + this._trackCalls = []; + const attributes: any = {}; + for (let i = 0; i < 1000; i++) { + attributes["attr." + i] = "value" + i; + } + + // Act + const span = this._ai.startSpan("many-attrs", { + kind: eOTelSpanKind.CLIENT, + attributes: attributes + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle many attributes"); + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.ok(Object.keys(props).length > 0, "Should have some properties"); + } + }); + + this.testCase({ + name: "Edge Case: Zero duration span", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - End span immediately + const span = this._ai.startSpan("instant-span", { + kind: eOTelSpanKind.CLIENT + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + const duration = (item.baseData as any).duration; + Assert.ok(duration !== undefined, "Should have duration field"); + } + }); + + this.testCase({ + name: "Edge Case: HTTP dependency with missing URL", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("http-no-url", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_HTTP_METHOD]: "GET", + [SEMATTRS_HTTP_STATUS_CODE]: 200 + // No URL attribute + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle missing URL"); + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "Http", "Should still be HTTP type"); + } + }); + + this.testCase({ + name: "Edge Case: Database dependency with missing statement", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("db-no-statement", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_DB_SYSTEM]: "postgresql", + [SEMATTRS_DB_NAME]: "testdb" + // No statement + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.equal((item.baseData as any).type, "postgresql", "Should have DB type"); + } + }); + } + + private addCrossBrowserCompatibilityTests(): void { + this.testCase({ + name: "Cross-Browser: Handles performance.now() unavailable", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - Create span when performance.now might not be available + const span = this._ai.startSpan("perf-test", { + kind: eOTelSpanKind.CLIENT + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should work without performance.now"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should generate valid telemetry"); + } + }); + + this.testCase({ + name: "Cross-Browser: Handles Date.now() for timing", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("date-timing", { + kind: eOTelSpanKind.CLIENT + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok((item.baseData as any).duration !== undefined, "Should have duration"); + Assert.ok((item.baseData as any).duration >= 0, "Duration should be non-negative"); + } + }); + + this.testCase({ + name: "Cross-Browser: String encoding compatibility", + test: () => { + // Arrange + this._trackCalls = []; + const testStrings = [ + "ASCII only", + "UTF-8: 你好世界", + "Emoji: 🎉🚀💻", + "Latin: café résumé", + "Mixed: Hello世界🌍" + ]; + + // Act + for (let i = 0; i < testStrings.length; i++) { + const span = this._ai.startSpan(testStrings[i], { + kind: eOTelSpanKind.CLIENT, + attributes: { + "test.string": testStrings[i] + } + }); + span?.end(); + } + + // Assert + Assert.equal(this._trackCalls.length, testStrings.length, "Should handle all encodings"); + for (let i = 0; i < testStrings.length; i++) { + const item = this._trackCalls[i]; + Assert.ok(item.baseData, "Should have baseData for encoding test " + i); + } + } + }); + + this.testCase({ + name: "Cross-Browser: JSON serialization of attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("json-test", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "number": 123, + "string": "test", + "boolean": true, + "float": 123.456, + "negative": -999, + "zero": 0 + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.equal(typeof props["number"], "number", "Numbers should remain numbers"); + Assert.equal(typeof props["string"], "string", "Strings should remain strings"); + Assert.equal(typeof props["boolean"], "boolean", "Booleans should remain booleans"); + } + }); + + this.testCase({ + name: "Cross-Browser: Large payload handling", + test: () => { + // Arrange + this._trackCalls = []; + const largeAttributes: any = {}; + for (let i = 0; i < 100; i++) { + largeAttributes["large.attr." + i] = "x".repeat(100); + } + + // Act + const span = this._ai.startSpan("large-payload", { + kind: eOTelSpanKind.CLIENT, + attributes: largeAttributes + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle large payloads"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should generate telemetry"); + } + }); + + this.testCase({ + name: "Cross-Browser: Handles undefined vs null attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("null-undefined", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "explicit.null": null as any, + "explicit.undefined": undefined as any, + "valid.value": "test" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.equal(props["valid.value"], "test", "Valid values should be preserved"); + } + }); + + this.testCase({ + name: "Cross-Browser: Whitespace handling in attribute keys", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("whitespace-keys", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "normal.key": "value1", + " leading.space": "value2", + "trailing.space ": "value3", + "has spaces": "value4" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle whitespace in keys"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "Cross-Browser: Number precision and special values", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("number-precision", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "max.safe.integer": Number.MAX_SAFE_INTEGER, + "min.safe.integer": Number.MIN_SAFE_INTEGER, + "large.float": 1.7976931348623157e+308, + "small.float": 5e-324, + "infinity": Infinity as any, + "neg.infinity": -Infinity as any, + "not.a.number": NaN as any + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle special number values"); + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.ok(props["max.safe.integer"] !== undefined, "Should handle large integers"); + } + }); + + this.testCase({ + name: "Cross-Browser: URL parsing with various formats", + test: () => { + // Arrange + this._trackCalls = []; + const urls = [ + "http://example.com", + "https://example.com:8080/path", + "http://example.com/path?query=value", + "https://user:pass@example.com/path", + "http://192.168.1.1:3000", + "https://[::1]:8080/path" + ]; + + // Act + for (const url of urls) { + const span = this._ai.startSpan("url-test", { + kind: eOTelSpanKind.CLIENT, + attributes: { + [SEMATTRS_HTTP_URL]: url + } + }); + span?.end(); + } + + // Assert + Assert.equal(this._trackCalls.length, urls.length, "Should handle all URL formats"); + } + }); + + this.testCase({ + name: "Cross-Browser: Timestamp handling across timezones", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("timezone-test", { + kind: eOTelSpanKind.CLIENT + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + Assert.ok(item.time, "Should have timestamp"); + const timestamp = new Date(item.time || "").getTime(); + Assert.ok(timestamp > 0, "Timestamp should be valid"); + } + }); + + this.testCase({ + name: "Cross-Browser: Memory efficient attribute storage", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - Create many spans to test memory handling + for (let i = 0; i < 10; i++) { + const span = this._ai.startSpan("memory-test-" + i, { + kind: eOTelSpanKind.CLIENT, + attributes: { + "iteration": i, + "data": "x".repeat(1000) + } + }); + span?.end(); + } + + // Assert + Assert.equal(this._trackCalls.length, 10, "Should handle multiple spans"); + Assert.ok(this._trackCalls[0].baseData, "First span should have data"); + Assert.ok(this._trackCalls[9].baseData, "Last span should have data"); + } + }); + + this.testCase({ + name: "Cross-Browser: Concurrent span operations", + test: () => { + // Arrange + this._trackCalls = []; + const spans: any[] = []; + + // Act - Create multiple spans before ending any + for (let i = 0; i < 5; i++) { + const span = this._ai.startSpan("concurrent-" + i, { + kind: eOTelSpanKind.CLIENT, + attributes: { + "index": i + } + }); + spans.push(span); + } + + // End all spans + for (const span of spans) { + span?.end(); + } + + // Assert + Assert.equal(this._trackCalls.length, 5, "Should handle concurrent spans"); + } + }); + + this.testCase({ + name: "Cross-Browser: RegExp in attribute values", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("regexp-test", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "pattern": "/test/gi" as any, + "normal": "value" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle RegExp-like values"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "Cross-Browser: Function and Symbol values filtered", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("special-types", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "function": (() => "test") as any, + "symbol": Symbol("test") as any, + "normal": "value" + } + }); + span?.end(); + + // Assert + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.equal(props["normal"], "value", "Normal values should be preserved"); + } + }); + + this.testCase({ + name: "Cross-Browser: Circular reference handling", + test: () => { + // Arrange + this._trackCalls = []; + const circular: any = { a: "value" }; + circular.self = circular; + + // Act + const span = this._ai.startSpan("circular-test", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "circular": circular, + "normal": "value" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should handle circular references gracefully"); + const item = this._trackCalls[0]; + const props = (item.baseData as any).properties || {}; + Assert.equal(props["normal"], "value", "Normal attributes should still work"); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/StartSpan.Tests.ts b/AISKU/Tests/Unit/src/StartSpan.Tests.ts new file mode 100644 index 000000000..0ba05e737 --- /dev/null +++ b/AISKU/Tests/Unit/src/StartSpan.Tests.ts @@ -0,0 +1,301 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { eOTelSpanKind, eOTelSpanStatusCode, ITelemetryItem } from "@microsoft/applicationinsights-core-js"; + +export class StartSpanTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${StartSpanTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + + // Track calls to track + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "StartSpanTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: StartSpanTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + // Initialize the SDK + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize tests: ' + e); + throw e; + } + } + + + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addTests(); + } + + private addTests(): void { + + this.testCase({ + name: "StartSpan: startSpan method should exist on ApplicationInsights instance", + test: () => { + // Verify that startSpan method exists + Assert.ok(this._ai, "ApplicationInsights should be initialized"); + Assert.ok(typeof this._ai.startSpan === 'function', "startSpan method should exist"); + + // Check core initialization + Assert.ok(this._ai.core, "Core should be available"); + const core = this._ai.core; + if (core) { + // Check if core has startSpan method + Assert.ok(typeof core.startSpan === 'function', "Core should have startSpan method"); + + // Test basic startSpan call on the core directly after initialization + const coreSpan = core.startSpan("debug-core-span"); + Assert.ok(coreSpan !== null, `Core startSpan returned ${coreSpan} instead of a span object`); + } + + // Test basic startSpan call after initialization + const span = this._ai.startSpan("debug-span"); + + // Should now return a valid span object + Assert.ok(span !== null, `startSpan returned ${span} instead of a span object`); + + Assert.ok(typeof span!.isRecording === 'function', "Span should have isRecording method"); + Assert.ok(typeof span!.end === 'function', "Span should have end method"); + const isRecording = span!.isRecording(); + Assert.ok(typeof isRecording === 'boolean', `isRecording should return boolean, got ${typeof isRecording}: ${isRecording}`); + } + }); + + this.testCase({ + name: "StartSpan: Recording span should trigger track when span ends", + test: () => { + // Clear previous calls + this._trackCalls = []; + + // Create a recording span using startSpan + const span = this._ai.startSpan("test-recording-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "test.attribute": "test-value", + "operation.type": "http" + } + }); + + Assert.ok(span, "Span should be created"); + + // Verify it's a recording span + Assert.ok(span!.isRecording(), "Span should be recording"); + + // End the span - this should trigger track via the onEnd callback + span!.end(); + + // Verify that track was called + Assert.equal(1, this._trackCalls.length, "track should have been called once for recording span"); + + // Add defensive check for the telemetry item + Assert.ok(this._trackCalls.length > 0, "Should have at least one track call"); + const item = this._trackCalls[0]; + Assert.ok(item, "Telemetry item should exist"); + Assert.ok(item.name, "Item name should be present"); + Assert.ok(item.baseData, "Base data should be present"); + + Assert.ok(item.baseData.properties, "Custom properties should be present"); + Assert.equal("test-value", item.baseData.properties["test.attribute"], "Should include span attributes"); + Assert.equal("http", item.baseData.properties["operation.type"], "Should include all span attributes"); + } + }); + + this.testCase({ + name: "StartSpan: Non-recording span should NOT trigger track when span ends", + test: () => { + // Clear previous calls + this._trackCalls = []; + + // NOTE: Currently all spans are recording by default + // When the recording: false option is implemented, this test will need to be updated + // For now, we'll create a regular span and document the expected behavior + const span = this._ai.startSpan("test-would-be-non-recording-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "test.attribute": "non-recording-value" + } + }); + + Assert.ok(span, "Span should be created"); + + // Currently, all spans are recording by default + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.ok(span!.isRecording(), "Span should be recording (default behavior)"); + + // End the span - this WILL trigger track since it's recording + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + + // Currently expecting 1 call since all spans are recording + // When non-recording spans are implemented, this should be 0 + Assert.equal(1, this._trackCalls.length, "track should be called for recording span (current default behavior)"); + + // TODO: Update this test when recording: false option is implemented + // The test should then use recording: false and expect 0 track calls + } + }); + + this.testCase({ + name: "StartSpan: Multiple recording spans should each trigger track", + test: () => { + // Clear previous calls + this._trackCalls = []; + + // Create multiple recording spans + const span1 = this._ai.startSpan("span-1", { + attributes: { "span.number": 1 } + }); + const span2 = this._ai.startSpan("span-2", { + attributes: { "span.number": 2 } + }); + + Assert.ok(span1 && span2, "Both spans should be created"); + + // End both spans + span1!.end(); + span2!.end(); + + // Should have two track calls + Assert.equal(2, this._trackCalls.length, "track should have been called twice"); + + // Verify both calls have the correct data + const item1 = this._trackCalls.find(item => + item.baseData && item.baseData.properties && item.baseData.name === "span-1"); + const item2 = this._trackCalls.find(item => + item.baseData && item.baseData.properties && item.baseData.name === "span-2"); + + Assert.ok(item1, "Should have item for span-1"); + Assert.ok(item2, "Should have item for span-2"); + + if (item1 && item2) { + Assert.equal(1, item1.baseData.properties["span.number"], "First span should have correct attribute"); + Assert.equal(2, item2.baseData.properties["span.number"], "Second span should have correct attribute"); + } + } + }); + + this.testCase({ + name: "StartSpan: Error recording spans should generate telemetry with error status", + test: () => { + // Clear previous calls + this._trackCalls = []; + + // Create an error span + const span = this._ai.startSpan("error-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "error": true, + "error.message": "Something went wrong" + } + }); + + Assert.ok(span, "Span should be created"); + + // Set error status on the span + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Test error occurred" + }); + + // End the span + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + + // Verify track was called + Assert.equal(1, this._trackCalls.length, "track should have been called once"); + + const item = this._trackCalls[0]; + Assert.ok(item, "Telemetry item should be present"); + Assert.ok(item.baseData, "Base data should be present"); + Assert.ok(item.baseData.properties, "Properties should be present"); + Assert.equal("error-span", item.baseData.name, "Should include span name"); + + + Assert.ok(item.baseData.properties, "Custom properties should be present"); + Assert.equal(true, item.baseData.properties["error"], "Should include error attribute"); + Assert.equal("Something went wrong", item.baseData.properties["error.message"], "Should include error message"); + } + }); + + this.testCase({ + name: "StartSpan: startSpan with parent context should work", + test: () => { + // Clear previous calls + this._trackCalls = []; + + // Create span with optional parent context parameter + // (We'll pass null for now since we're not testing context propagation yet) + const parentContext = null; + + // Create span with parent context + const span = this._ai.startSpan("child-span", { + attributes: { "has.parent": false } + }); + + Assert.ok(span, "Span should be created with parent context"); + + // End the span + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + span!.end(); + + // Verify track was called + Assert.equal(1, this._trackCalls.length, "track should have been called once"); + + const item = this._trackCalls[0]; + Assert.ok(item, "Telemetry item should be present"); + Assert.ok(item.baseData && item.baseData.properties, "Properties should be present"); + Assert.equal("child-span", item.baseData.name, "Should include span name"); + Assert.equal(false, item.baseData.properties["has.parent"], "Should include span attributes"); + } + }); + + this.testCase({ + name: "StartSpan: startSpan should return valid span when trace provider is available", + test: () => { + // After initialization, the trace provider should be available + // and startSpan should return a valid span object + const span = this._ai.startSpan("test-span"); + + // Now that initialization is complete, we should get a valid span + Assert.ok(span !== null, "startSpan should return a valid span after initialization"); + Assert.ok(typeof span === 'object', "Span should be an object"); + + Assert.ok(typeof span!.end === 'function', "Span should have end method"); + Assert.ok(typeof span!.isRecording === 'function', "Span should have isRecording method"); + span!.end(); + } + }); + } +} \ No newline at end of file diff --git a/AISKU/Tests/Unit/src/TelemetryItemGeneration.Tests.ts b/AISKU/Tests/Unit/src/TelemetryItemGeneration.Tests.ts new file mode 100644 index 000000000..9d827180c --- /dev/null +++ b/AISKU/Tests/Unit/src/TelemetryItemGeneration.Tests.ts @@ -0,0 +1,831 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { eOTelSpanKind, eOTelSpanStatusCode, ITelemetryItem } from "@microsoft/applicationinsights-core-js"; + +export class TelemetryItemGenerationTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${TelemetryItemGenerationTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "TelemetryItemGenerationTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: TelemetryItemGenerationTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize tests: ' + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addSpanKindTests(); + this.addStatusCodeTests(); + this.addAttributeTests(); + this.addTelemetryItemStructureTests(); + this.addComplexScenarioTests(); + } + + private addSpanKindTests(): void { + this.testCase({ + name: "SpanKind: INTERNAL span generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("internal-operation", { + kind: eOTelSpanKind.INTERNAL, + attributes: { "operation.name": "internal-task" } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate one telemetry item"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + Assert.ok(item.baseData.properties, "Should have properties"); + } + }); + + this.testCase({ + name: "SpanKind: CLIENT span generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("client-request", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "GET", + "http.url": "https://example.com/api", + "custom.attribute": "custom-value" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate one telemetry item"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + // Semantic attributes like http.method are excluded from properties + Assert.ok(!item.baseData.properties || !item.baseData.properties["http.method"], + "http.method should not be in properties (mapped to baseData)"); + // Custom attributes should be in properties + Assert.equal(item.baseData.properties?.["custom.attribute"], "custom-value", + "Custom attributes should be in properties"); + } + }); + + this.testCase({ + name: "SpanKind: SERVER span generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("server-handler", { + kind: eOTelSpanKind.SERVER, + attributes: { + "http.method": "POST", + "http.status_code": 200, + "custom.server.id": "server-123" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate one telemetry item"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + // Semantic attributes are excluded from properties + Assert.ok(!item.baseData.properties || !item.baseData.properties["http.method"], + "http.method should not be in properties (mapped to baseData)"); + // Custom attributes should be in properties + Assert.equal(item.baseData.properties?.["custom.server.id"], "server-123", + "Custom attributes should be in properties"); + } + }); + + this.testCase({ + name: "SpanKind: PRODUCER span generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("message-producer", { + kind: eOTelSpanKind.PRODUCER, + attributes: { + "messaging.system": "kafka", + "messaging.destination": "orders-topic", + "producer.id": "producer-456" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate one telemetry item"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + // messaging.* attributes may or may not be excluded depending on semantic conventions + // Custom attributes should be in properties + Assert.equal(item.baseData.properties?.["producer.id"], "producer-456", + "Custom attributes should be in properties"); + } + }); + + this.testCase({ + name: "SpanKind: CONSUMER span generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("message-consumer", { + kind: eOTelSpanKind.CONSUMER, + attributes: { + "messaging.system": "rabbitmq", + "messaging.operation": "receive", + "consumer.group": "group-789" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate one telemetry item"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + // messaging.* attributes may or may not be excluded depending on semantic conventions + // Custom attributes should be in properties + Assert.equal(item.baseData.properties?.["consumer.group"], "group-789", + "Custom attributes should be in properties"); + } + }); + + this.testCase({ + name: "SpanKind: all span kinds generate independent telemetry", + test: () => { + // Arrange + this._trackCalls = []; + const spanKinds = [ + eOTelSpanKind.INTERNAL, + eOTelSpanKind.CLIENT, + eOTelSpanKind.SERVER, + eOTelSpanKind.PRODUCER, + eOTelSpanKind.CONSUMER + ]; + + // Act + spanKinds.forEach((kind, index) => { + const span = this._ai.startSpan(`span-kind-${index}`, { kind }); + span?.end(); + }); + + // Assert + Assert.equal(this._trackCalls.length, spanKinds.length, + "Each span kind should generate telemetry"); + } + }); + } + + private addStatusCodeTests(): void { + this.testCase({ + name: "StatusCode: UNSET status generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("unset-status-span"); + // Don't set status - defaults to UNSET + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "StatusCode: OK status generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("ok-status-span"); + span?.setStatus({ + code: eOTelSpanStatusCode.OK, + message: "Operation successful" + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "StatusCode: ERROR status generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("error-status-span"); + span?.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Operation failed" + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "StatusCode: status with message includes message in telemetry", + test: () => { + // Arrange + this._trackCalls = []; + const errorMessage = "Database connection timeout"; + + // Act + const span = this._ai.startSpan("status-with-message"); + span?.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: errorMessage + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + // Note: Implementation may include status message in properties or elsewhere + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData with status information"); + } + }); + + this.testCase({ + name: "StatusCode: changing status before end affects telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("changing-status-span"); + span?.setStatus({ code: eOTelSpanStatusCode.OK }); + span?.setStatus({ code: eOTelSpanStatusCode.ERROR, message: "Changed to error" }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + // The final status (ERROR) should be reflected in telemetry + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData with final status"); + } + }); + + this.testCase({ + name: "StatusCode: multiple spans with different statuses", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span1 = this._ai.startSpan("span-ok"); + span1?.setStatus({ code: eOTelSpanStatusCode.OK }); + span1?.end(); + + const span2 = this._ai.startSpan("span-error"); + span2?.setStatus({ code: eOTelSpanStatusCode.ERROR }); + span2?.end(); + + const span3 = this._ai.startSpan("span-unset"); + // No status set + span3?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 3, "Should generate telemetry for all spans"); + } + }); + } + + private addAttributeTests(): void { + this.testCase({ + name: "Attributes: span with no attributes generates telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("no-attributes-span"); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + } + }); + + this.testCase({ + name: "Attributes: span with string attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("string-attrs-span", { + attributes: { + "user.id": "user123", + "session.id": "session456", + "operation.name": "checkout" + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + // These custom attributes should be in properties + Assert.equal(item.baseData.properties["user.id"], "user123", + "Should include custom string attributes"); + Assert.equal(item.baseData.properties["session.id"], "session456", + "Should include custom string attributes"); + // operation.name is a context tag key and gets excluded from properties + } + }); + + this.testCase({ + name: "Attributes: span with number attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("number-attrs-span", { + attributes: { + "request.size": 1024, + "response.time": 156.78, + "retry.count": 3 + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + // Custom number attributes should be in properties + Assert.equal(item.baseData.properties["request.size"], 1024, + "Should include custom number attributes"); + Assert.equal(item.baseData.properties["response.time"], 156.78, + "Should include custom number attributes"); + } + }); + + this.testCase({ + name: "Attributes: span with boolean attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("boolean-attrs-span", { + attributes: { + "cache.hit": true, + "auth.required": false, + "retry.enabled": true + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + Assert.equal(item.baseData.properties["cache.hit"], true, + "Should include boolean attributes"); + } + }); + + this.testCase({ + name: "Attributes: span with mixed type attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("mixed-attrs-span", { + attributes: { + "string.attr": "value", + "number.attr": 42, + "boolean.attr": true, + "float.attr": 3.14 + } + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + Assert.equal(item.baseData.properties["string.attr"], "value"); + Assert.equal(item.baseData.properties["number.attr"], 42); + Assert.equal(item.baseData.properties["boolean.attr"], true); + } + }); + + this.testCase({ + name: "Attributes: setAttribute after creation adds to telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("dynamic-attrs-span"); + span?.setAttribute("initial.attr", "initial"); + span?.setAttribute("added.later", "later-value"); + span?.setAttribute("number.added", 999); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + Assert.equal(item.baseData.properties["initial.attr"], "initial"); + Assert.equal(item.baseData.properties["added.later"], "later-value"); + Assert.equal(item.baseData.properties["number.added"], 999); + } + }); + + this.testCase({ + name: "Attributes: setAttributes adds multiple attributes to telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("batch-attrs-span"); + span?.setAttributes({ + "batch.attr1": "value1", + "batch.attr2": "value2", + "batch.attr3": 123 + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + Assert.equal(item.baseData.properties["batch.attr1"], "value1"); + Assert.equal(item.baseData.properties["batch.attr2"], "value2"); + Assert.equal(item.baseData.properties["batch.attr3"], 123); + } + }); + + this.testCase({ + name: "Attributes: updating attribute value reflects in telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("update-attr-span"); + span?.setAttribute("status", "pending"); + span?.setAttribute("status", "in-progress"); + span?.setAttribute("status", "completed"); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + Assert.equal(item.baseData.properties["status"], "completed", + "Should reflect final attribute value"); + } + }); + } + + private addTelemetryItemStructureTests(): void { + this.testCase({ + name: "Structure: telemetry item has required fields", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("structure-test-span"); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + + Assert.ok(item.name, "Should have name"); + Assert.ok(item.baseData, "Should have baseData"); + Assert.ok(item.baseData.properties, "Should have properties"); + } + }); + + this.testCase({ + name: "Structure: span name is in telemetry", + test: () => { + // Arrange + this._trackCalls = []; + const spanName = "my-custom-operation"; + + // Act + const span = this._ai.startSpan(spanName); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + + // Span name is in baseData.name, not properties.name + Assert.ok(item.baseData, "Should have baseData"); + Assert.equal(item.baseData.name, spanName, + "Span name should be in baseData.name"); + } + }); + + this.testCase({ + name: "Structure: updated span name reflects in telemetry", + test: () => { + // Arrange + this._trackCalls = []; + const originalName = "original-name"; + const updatedName = "updated-name"; + + // Act + const span = this._ai.startSpan(originalName); + span?.updateName(updatedName); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + + // Updated span name should be in baseData.name + Assert.ok(item.baseData, "Should have baseData"); + Assert.equal(item.baseData.name, updatedName, + "Updated span name should be in baseData.name"); + } + }); + + this.testCase({ + name: "Structure: trace context is in telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("trace-context-span"); + const spanContext = span?.spanContext(); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + + // Telemetry should include trace context information + Assert.ok(spanContext, "Span should have context"); + Assert.ok(spanContext?.traceId, "Should have traceId"); + Assert.ok(spanContext?.spanId, "Should have spanId"); + } + }); + + this.testCase({ + name: "Structure: multiple spans generate separate telemetry items", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span1 = this._ai.startSpan("span-1"); + span1?.end(); + + const span2 = this._ai.startSpan("span-2"); + span2?.end(); + + const span3 = this._ai.startSpan("span-3"); + span3?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 3, "Should generate 3 telemetry items"); + + // Span names are in baseData.name, not properties.name + const names = this._trackCalls.map(item => item.baseData?.name); + Assert.ok(names.includes("span-1"), "Should include span-1"); + Assert.ok(names.includes("span-2"), "Should include span-2"); + Assert.ok(names.includes("span-3"), "Should include span-3"); + } + }); + } + + private addComplexScenarioTests(): void { + this.testCase({ + name: "Complex: span with kind, status, and attributes", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("complex-span", { + kind: eOTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://api.example.com/users", + "http.status_code": 201, + "request.id": "req-12345", + "user.action": "create" + } + }); + span?.setStatus({ + code: eOTelSpanStatusCode.OK, + message: "User created successfully" + }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData, "Should have baseData"); + // Semantic attributes are excluded from properties + Assert.ok(!item.baseData.properties || !item.baseData.properties["http.method"], + "http.method should not be in properties"); + // Custom attributes should be in properties + Assert.equal(item.baseData.properties?.["request.id"], "req-12345", + "Custom attributes should be in properties"); + Assert.equal(item.baseData.properties?.["user.action"], "create", + "Custom attributes should be in properties"); + } + }); + + this.testCase({ + name: "Complex: parent-child spans generate separate telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const parentSpan = this._ai.startSpan("parent-operation"); + const parentContext = parentSpan?.spanContext(); + + const childSpan = this._ai.startSpan("child-operation", undefined, parentContext); + childSpan?.end(); + + parentSpan?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 2, "Should generate telemetry for both spans"); + + // Span names are in baseData.name, not properties.name + const names = this._trackCalls.map(item => item.baseData?.name); + Assert.ok(names.includes("child-operation"), "Should include child telemetry"); + Assert.ok(names.includes("parent-operation"), "Should include parent telemetry"); + } + }); + + this.testCase({ + name: "Complex: span with dynamic attributes during execution", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const span = this._ai.startSpan("dynamic-execution-span", { + attributes: { "phase": "start" } + }); + + span?.setAttribute("phase", "processing"); + span?.setAttribute("items.processed", 50); + + span?.setAttribute("phase", "finalizing"); + span?.setAttribute("items.processed", 100); + + span?.setStatus({ code: eOTelSpanStatusCode.OK }); + span?.end(); + + // Assert + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + const item = this._trackCalls[0]; + Assert.ok(item.baseData?.properties, "Should have properties"); + Assert.equal(item.baseData.properties["phase"], "finalizing", + "Should have final phase value"); + Assert.equal(item.baseData.properties["items.processed"], 100, + "Should have final processed count"); + } + }); + + this.testCase({ + name: "Complex: all span kinds with attributes and status", + test: () => { + // Arrange + this._trackCalls = []; + const testData = [ + { kind: eOTelSpanKind.INTERNAL, name: "internal-op", attr: "internal-value" }, + { kind: eOTelSpanKind.CLIENT, name: "client-op", attr: "client-value" }, + { kind: eOTelSpanKind.SERVER, name: "server-op", attr: "server-value" }, + { kind: eOTelSpanKind.PRODUCER, name: "producer-op", attr: "producer-value" }, + { kind: eOTelSpanKind.CONSUMER, name: "consumer-op", attr: "consumer-value" } + ]; + + // Act + testData.forEach(data => { + const span = this._ai.startSpan(data.name, { + kind: data.kind, + attributes: { "operation.type": data.attr } + }); + span?.setStatus({ code: eOTelSpanStatusCode.OK }); + span?.end(); + }); + + // Assert + Assert.equal(this._trackCalls.length, testData.length, + "Should generate telemetry for all span types"); + + testData.forEach(data => { + // Span names are in baseData.name, not properties.name + const telemetry = this._trackCalls.find( + item => item.baseData?.name === data.name + ); + Assert.ok(telemetry, `Should have telemetry for ${data.name}`); + // Custom attributes should be in properties + Assert.equal(telemetry?.baseData?.properties?.["operation.type"], data.attr, + `Should have correct attributes for ${data.name}`); + }); + } + }); + + this.testCase({ + name: "Complex: non-recording spans do not generate telemetry", + test: () => { + // Arrange + this._trackCalls = []; + + // Act + const recordingSpan = this._ai.startSpan("recording-span", { recording: true }); + recordingSpan?.end(); + + const nonRecordingSpan = this._ai.startSpan("non-recording-span", { recording: false }); + nonRecordingSpan?.end(); + + // Assert + // Recording span should generate telemetry, non-recording should not + // Span names are in baseData.name, not properties.name + const recordingTelemetry = this._trackCalls.find( + item => item.baseData?.name === "recording-span" + ); + const nonRecordingTelemetry = this._trackCalls.find( + item => item.baseData?.name === "non-recording-span" + ); + + Assert.ok(recordingTelemetry, "Recording span should generate telemetry"); + Assert.ok(!nonRecordingTelemetry, "Non-recording span should not generate telemetry"); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/TraceContext.Tests.ts b/AISKU/Tests/Unit/src/TraceContext.Tests.ts new file mode 100644 index 000000000..49b75be42 --- /dev/null +++ b/AISKU/Tests/Unit/src/TraceContext.Tests.ts @@ -0,0 +1,735 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { IOTelSpanOptions, eOTelSpanKind, ITelemetryItem, isUndefined, useSpan, isNumber } from "@microsoft/applicationinsights-core-js"; +import { isFunction, objIs } from '@nevware21/ts-utils'; + +export class TraceContextTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${TraceContextTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "TraceContextTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: TraceContextTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize tests: ' + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addGetTraceCtxTests(); + this.addActiveSpanTests(); + this.addsetActiveSpanTests(); + this.addIntegrationTests(); + } + + private addGetTraceCtxTests(): void { + this.testCase({ + name: "getTraceCtx: should return valid trace context after starting a span", + test: () => { + // Arrange + const spanName = "test-span-with-context"; + + // Act + const span = this._ai.startSpan(spanName); + const traceCtx = this._ai.getTraceCtx(); + + // Assert + Assert.ok(span !== null, "Span should be created"); + Assert.ok(traceCtx !== null && traceCtx !== undefined, "Should return trace context after starting span"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.ok(traceCtx!.traceId, "Trace context should have traceId"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.equal("", traceCtx!.spanId, "Trace context should not have a spanId (the default SDK initialization)"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.ok(isUndefined(traceCtx!.traceFlags), "Trace context should NOT have have traceFlags"); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + useSpan(this._ai.core, span!, () => { + const nestedTraceCtx = this._ai.getTraceCtx(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Assert.equal(traceCtx!.traceId, nestedTraceCtx!.traceId, "TraceId should be the same within the span context"); + Assert.equal(span?.spanContext().traceId, nestedTraceCtx?.traceId, "TraceId should match the active span's traceId"); + Assert.equal(span?.spanContext().spanId, nestedTraceCtx?.spanId, "SpanId should match the active span's spanId"); + Assert.equal(span?.spanContext().traceFlags, nestedTraceCtx?.traceFlags, "TraceFlags should match the active span's traceFlags"); + }); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "getTraceCtx: should return trace context matching active span", + test: () => { + // Arrange + const spanName = "context-matching-span"; + + // Act + const span = this._ai.startSpan(spanName); + const traceCtx = this._ai.getTraceCtx(); + const spanContext = span?.spanContext(); + + // Assert + Assert.ok(span !== null, "Span should be created"); + Assert.ok(traceCtx !== null && traceCtx !== undefined, "Trace context should exist"); + Assert.ok(spanContext !== null && spanContext !== undefined, "Span context should exist"); + + Assert.equal(traceCtx.traceId, spanContext.traceId, "Trace context traceId should match span context"); + Assert.notEqual(traceCtx.spanId, spanContext.spanId, "Trace context spanId should match span context"); + + useSpan(this._ai.core, span!, () => { + const activeTraceCtx = this._ai.getTraceCtx(); + Assert.equal(activeTraceCtx?.traceId, spanContext.traceId, "The active traceId should match span context"); + Assert.equal(activeTraceCtx?.spanId, spanContext.spanId, "The active spanId should match span context"); + }); + + Assert.equal(traceCtx.traceId, spanContext.traceId, "Trace context traceId should match span context"); + Assert.notEqual(traceCtx.spanId, spanContext.spanId, "Trace context spanId should match span context"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "getTraceCtx: should have valid traceId format (32 hex chars)", + test: () => { + // Arrange + const span = this._ai.startSpan("format-test-span"); + + // Act + const traceCtx = this._ai.getTraceCtx(); + + // Assert + Assert.ok(traceCtx !== null && traceCtx !== undefined, "Trace context should exist"); + + if (traceCtx && traceCtx.traceId) { + Assert.equal(traceCtx.traceId.length, 32, "TraceId should be 32 characters"); + Assert.ok(/^[0-9a-f]{32}$/i.test(traceCtx.traceId), + "TraceId should be 32 hex characters"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "getTraceCtx: should have valid spanId format (16 hex chars)", + test: () => { + // Arrange + const span = this._ai.startSpan("spanid-test-span"); + + // Act + const traceCtx = this._ai.getTraceCtx(); + + // Assert + Assert.ok(traceCtx !== null && traceCtx !== undefined, "Trace context should exist"); + + if (traceCtx && traceCtx.spanId) { + Assert.equal(traceCtx.spanId.length, 16, "SpanId should be 16 characters"); + Assert.ok(/^[0-9a-f]{16}$/i.test(traceCtx.spanId), + "SpanId should be 16 hex characters"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "getTraceCtx: should persist context across multiple calls", + test: () => { + // Arrange + const span = this._ai.startSpan("persistence-span"); + + // Act + const traceCtx1 = this._ai.getTraceCtx(); + const traceCtx2 = this._ai.getTraceCtx(); + const traceCtx3 = this._ai.getTraceCtx(); + + // Assert + Assert.ok(traceCtx1 !== null && traceCtx1 !== undefined, "First call should return context"); + Assert.ok(traceCtx2 !== null && traceCtx2 !== undefined, "Second call should return context"); + Assert.ok(traceCtx3 !== null && traceCtx3 !== undefined, "Third call should return context"); + + if (traceCtx1 && traceCtx2 && traceCtx3) { + Assert.equal(traceCtx1.traceId, traceCtx2.traceId, "TraceId should be consistent"); + Assert.equal(traceCtx2.traceId, traceCtx3.traceId, "TraceId should be consistent"); + Assert.equal(traceCtx1.spanId, traceCtx2.spanId, "SpanId should be consistent"); + Assert.equal(traceCtx2.spanId, traceCtx3.spanId, "SpanId should be consistent"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "getTraceCtx: should return context for child spans with same traceId", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-span"); + const parentCtx = this._ai.getTraceCtx(); + + // Act - create child span + const childSpan = this._ai.startSpan("child-span", undefined, parentCtx || undefined); + + let childCtx; + useSpan(this._ai.core, childSpan!, () => { + childCtx = this._ai.getTraceCtx(); + }); + + // Assert + Assert.ok(parentCtx !== null && parentCtx !== undefined, "Parent context should exist"); + Assert.ok(childCtx !== null && childCtx !== undefined, "Child context should exist"); + + Assert.equal(childCtx.traceId, parentCtx.traceId, "Child span should have same traceId as parent"); + Assert.notEqual(childCtx.spanId, parentCtx.spanId, "Child span should have different spanId from parent"); + + Assert.equal(childSpan?.spanContext().traceId, parentCtx.traceId, "Child span should have same traceId as parent"); + Assert.notEqual(childSpan?.spanContext().spanId, parentCtx.spanId, "Child span should have different spanId from parent"); + Assert.equal(childSpan?.spanContext().spanId, childCtx.spanId, "Child spanId should match its context"); + Assert.equal(childSpan?.spanContext().traceId, childCtx.traceId, "Child traceId should match its context"); + + // Cleanup + childSpan?.end(); + parentSpan?.end(); + } + }); + } + + private addActiveSpanTests(): void { + this.testCase({ + name: "activeSpan: should return null when no span is active (via trace provider)", + test: () => { + // Assert + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const activeSpan = this._ai.getActiveSpan(); + Assert.ok(activeSpan, "Should Always return a non-null span when no span is active"); + Assert.equal(false, activeSpan.isRecording(), "Returned span should be a non-recording span"); + } + }); + + this.testCase({ + name: "activeSpan: should return null when createNew is false and no span is active", + test: () => { + // Act + const activeSpan = this._ai.getActiveSpan(false); + + // Assert + Assert.equal(activeSpan, null, "Should return null when createNew is false and no active span exists"); + } + }); + + this.testCase({ + name: "activeSpan: should return existing span when createNew is false and span is active", + test: () => { + // Arrange + const span = this._ai.startSpan("test-span"); + this._ai.setActiveSpan(span); + + // Act + const activeSpan = this._ai.getActiveSpan(false); + + // Assert + Assert.ok(activeSpan, "Should return the active span"); + Assert.equal(activeSpan, span, "Should return the same span object"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "activeSpan: should return active span after setActiveSpan (via trace provider)", + test: () => { + // Arrange + const span = this._ai.startSpan("active-span-test"); + + // Act + this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(activeSpan !== null, "Should return the active span"); + Assert.equal(activeSpan.name, span.name, "Active span should match the set span"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "activeSpan: should return the most recently set active span", + test: () => { + // Arrange + const span1 = this._ai.startSpan("span-1"); + const span2 = this._ai.startSpan("span-2"); + const span3 = this._ai.startSpan("span-3"); + + // Act & Assert + this._ai.setActiveSpan(span1); + let activeSpan = this._ai.getActiveSpan(); + Assert.equal(activeSpan.name, span1.name, "Should return span1 as active"); + + this._ai.setActiveSpan(span2); + activeSpan = this._ai.getActiveSpan(); + Assert.equal(activeSpan.name, span2.name, "Should return span2 as active"); + + this._ai.setActiveSpan(span3); + activeSpan = this._ai.getActiveSpan(); + Assert.equal(activeSpan.name, span3.name, "Should return span3 as active"); + + // Cleanup + span1?.end(); + span2?.end(); + span3?.end(); + } + }); + + this.testCase({ + name: "activeSpan: active span should have valid span context", + test: () => { + // Arrange + const span = this._ai.startSpan("context-check-span"); + + // Act + this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + const spanContext = activeSpan.spanContext(); + + // Assert + Assert.ok(activeSpan !== null, "Active span should exist"); + Assert.ok(objIs(activeSpan, span), "Active span should match the set span"); + Assert.ok(spanContext !== null && spanContext !== undefined, "Active span should have valid context"); + + Assert.ok(spanContext.traceId, "Should have traceId"); + Assert.ok(spanContext.spanId, "Should have spanId"); + Assert.ok(isUndefined(spanContext.traceFlags), "Should have default traceFlags (undefined)"); + Assert.equal(undefined, spanContext?.traceFlags, "TraceFlags should not have sampled flag set by default"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "activeSpan: should work with recording spans", + test: () => { + // Arrange + const options: IOTelSpanOptions = { + recording: true, + kind: eOTelSpanKind.CLIENT + }; + const span = this._ai.startSpan("recording-span", options); + + // Act + this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(activeSpan !== null, "Active span should exist"); + Assert.ok(activeSpan.isRecording(), "Active span should be recording"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "activeSpan: should work with non-recording spans", + test: () => { + // Arrange + const options: IOTelSpanOptions = { + recording: false + }; + const span = this._ai.startSpan("non-recording-span", options); + + // Act + this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(activeSpan !== null, "Active span should exist"); + Assert.ok(!activeSpan.isRecording(), "Active span should not be recording"); + + // Cleanup + span?.end(); + } + }); + } + + private addsetActiveSpanTests(): void { + this.testCase({ + name: "setActiveSpan: should set a span as active", + test: () => { + // Arrange + const span = this._ai.startSpan("set-active-test"); + + // Act + const scope = this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(scope !== null, "Scope should be returned"); + Assert.equal(scope.span, span, "Scope.span should equal the passed span"); + Assert.ok(activeSpan !== null, "Active span should be set"); + Assert.equal(activeSpan, span, "ActiveSpan() should return the same span object"); + Assert.equal(activeSpan.name, span.name, "Set span should be the active span"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "setActiveSpan: should update getTraceCtx to reflect active span", + test: () => { + // Arrange + const span = this._ai.startSpan("trace-ctx-update-test"); + + // Act + const scope = this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + const traceCtx = this._ai.getTraceCtx(); + const spanContext = span.spanContext(); + + // Assert + Assert.ok(scope !== null, "Scope should be returned"); + Assert.equal(scope.span, span, "Scope.span should equal the passed span"); + Assert.equal(activeSpan, span, "ActiveSpan() should return the same span object"); + Assert.ok(traceCtx !== null && traceCtx !== undefined, + "Trace context should be updated"); + + if (traceCtx && spanContext) { + Assert.equal(traceCtx.traceId, spanContext.traceId, + "Trace context should match active span"); + Assert.equal(traceCtx.spanId, spanContext.spanId, + "Trace context spanId should match active span"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "setActiveSpan: should allow switching between multiple spans", + test: () => { + // Arrange + const span1 = this._ai.startSpan("switch-span-1"); + const span2 = this._ai.startSpan("switch-span-2"); + + // Act & Assert + // Set first span as active + let scope = this._ai.setActiveSpan(span1); + let activeSpan = this._ai.getActiveSpan(); + Assert.equal(scope.span, span1, "Scope.span should equal span1"); + Assert.equal(activeSpan, span1, "ActiveSpan() should return span1"); + Assert.equal(activeSpan.name, span1.name, "First span should be active"); + + // Switch to second span + scope = this._ai.setActiveSpan(span2); + activeSpan = this._ai.getActiveSpan(); + Assert.equal(scope.span, span2, "Scope.span should equal span2"); + Assert.equal(activeSpan, span2, "ActiveSpan() should return span2"); + Assert.equal(activeSpan.name, span2.name, "Second span should be active"); + + // Switch back to first span + scope = this._ai.setActiveSpan(span1); + activeSpan = this._ai.getActiveSpan(); + Assert.equal(scope.span, span1, "Scope.span should equal span1 again"); + Assert.equal(activeSpan, span1, "ActiveSpan() should return span1 again"); + Assert.equal(activeSpan.name, span1.name, "First span should be active again"); + + // Cleanup + span1?.end(); + span2?.end(); + } + }); + + this.testCase({ + name: "setActiveSpan: should work with spans of different kinds", + test: () => { + // Arrange + const clientSpan = this._ai.startSpan("client-span", { kind: eOTelSpanKind.CLIENT }); + const serverSpan = this._ai.startSpan("server-span", { kind: eOTelSpanKind.SERVER }); + + // Act & Assert + let scope = this._ai.setActiveSpan(clientSpan); + let activeSpan = this._ai.getActiveSpan(); + Assert.equal(scope.span, clientSpan, "Scope.span should equal clientSpan"); + Assert.equal(activeSpan, clientSpan, "ActiveSpan() should return clientSpan"); + Assert.equal(activeSpan.kind, eOTelSpanKind.CLIENT, + "Client span should be active with correct kind"); + + scope = this._ai.setActiveSpan(serverSpan); + activeSpan = this._ai.getActiveSpan(); + Assert.equal(scope.span, serverSpan, "Scope.span should equal serverSpan"); + Assert.equal(activeSpan, serverSpan, "ActiveSpan() should return serverSpan"); + Assert.equal(activeSpan.kind, eOTelSpanKind.SERVER, + "Server span should be active with correct kind"); + + // Cleanup + clientSpan?.end(); + serverSpan?.end(); + } + }); + + this.testCase({ + name: "setActiveSpan: should work with spans that have attributes", + test: () => { + // Arrange + const attributes = { + "http.method": "GET", + "http.url": "https://example.com", + "custom.attribute": "test-value" + }; + const span = this._ai.startSpan("attributed-span", { attributes }); + + // Act + const scope = this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(scope !== null, "Scope should be returned"); + Assert.equal(scope.span, span, "Scope.span should equal the passed span"); + Assert.ok(activeSpan !== null, "Active span should exist"); + Assert.equal(activeSpan, span, "ActiveSpan() should return the same span object"); + Assert.equal(activeSpan.name, span.name, "Span name should match"); + + const spanAttributes = activeSpan.attributes; + Assert.equal(spanAttributes["http.method"], "GET", + "Attributes should be preserved"); + Assert.equal(spanAttributes["http.url"], "https://example.com", + "Attributes should be preserved"); + Assert.equal(spanAttributes["custom.attribute"], "test-value", + "Custom attributes should be preserved"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "setActiveSpan: should handle ended spans", + test: () => { + // Arrange + const span = this._ai.startSpan("ended-span"); + + // Act + span.end(); + + const scope = this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(scope !== null, "Scope should be returned"); + Assert.equal(scope.span, span, "Scope.span should equal the passed ended span"); + Assert.ok(activeSpan !== null, "Should be able to set ended span as active"); + Assert.equal(activeSpan, span, "ActiveSpan() should return the same ended span object"); + Assert.ok(activeSpan.ended, "Active span should be marked as ended"); + + // Cleanup is already done (span.end() called) + } + }); + } + + private addIntegrationTests(): void { + this.testCase({ + name: "Integration: getTraceCtx, activeSpan, and setActiveSpan should work together", + test: () => { + // Arrange + const span1 = this._ai.startSpan("integration-span-1"); + const span2 = this._ai.startSpan("integration-span-2"); + + // Act & Assert - Set first span active + this._ai.setActiveSpan(span1); + + let activeSpan = this._ai.getActiveSpan(); + let traceCtx = this._ai.getTraceCtx(); + let span1Context = span1.spanContext(); + + Assert.equal(activeSpan.name, span1.name, "Active span should be span1"); + Assert.equal(traceCtx?.spanId, span1Context.spanId, + "Trace context should match span1"); + + // Switch to second span + this._ai.setActiveSpan(span2); + + activeSpan = this._ai.getActiveSpan(); + traceCtx = this._ai.getTraceCtx(); + let span2Context = span2.spanContext(); + + Assert.equal(activeSpan.name, span2.name, "Active span should be span2"); + Assert.equal(traceCtx?.spanId, span2Context.spanId, + "Trace context should match span2"); + + // Cleanup + span1?.end(); + span2?.end(); + } + }); + + this.testCase({ + name: "Integration: parent-child span relationship via getTraceCtx", + test: () => { + // Arrange & Act + const parentSpan = this._ai.startSpan("integration-parent"); + + this._ai.setActiveSpan(parentSpan); + const parentCtx = this._ai.getTraceCtx(); + + // Create child span using parent context + const childSpan = this._ai.startSpan("integration-child", undefined, parentCtx || undefined); + this._ai.setActiveSpan(childSpan); + + const childCtx = this._ai.getTraceCtx(); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(parentCtx !== null && parentCtx !== undefined, "Parent context should exist"); + Assert.ok(childCtx !== null && childCtx !== undefined, "Child context should exist"); + + Assert.equal(childCtx.traceId, parentCtx.traceId, "Child should inherit parent's traceId"); + Assert.notEqual(childCtx.spanId, parentCtx.spanId, "Child should have different spanId"); + + Assert.equal(activeSpan.name, "integration-child", "Active span should be the child span"); + + // Cleanup + childSpan?.end(); + + parentSpan?.end(); + } + }); + + this.testCase({ + name: "Integration: multiple spans with trace context propagation", + test: () => { + // Arrange + const rootSpan = this._ai.startSpan("root-span"); + + this._ai.setActiveSpan(rootSpan); + const rootCtx = this._ai.getTraceCtx(); + + // Create first child + const child1Span = this._ai.startSpan("child-1", undefined, rootCtx || undefined); + this._ai.setActiveSpan(child1Span); + const child1Ctx = this._ai.getTraceCtx(); + + // Create second child (sibling to first child) + const child2Span = this._ai.startSpan("child-2", undefined, rootCtx || undefined); + this._ai.setActiveSpan(child2Span); + const child2Ctx = this._ai.getTraceCtx(); + + // Assert - all should share the same traceId + Assert.equal(child1Ctx.traceId, rootCtx.traceId, + "Child 1 should share root traceId"); + Assert.equal(child2Ctx.traceId, rootCtx.traceId, + "Child 2 should share root traceId"); + + // But have different spanIds + Assert.notEqual(child1Ctx.spanId, rootCtx.spanId, + "Child 1 should have different spanId"); + Assert.notEqual(child2Ctx.spanId, rootCtx.spanId, + "Child 2 should have different spanId"); + Assert.notEqual(child1Ctx.spanId, child2Ctx.spanId, + "Siblings should have different spanIds"); + + // Cleanup + child2Span?.end(); + child1Span?.end(); + + rootSpan?.end(); + } + }); + + this.testCase({ + name: "Integration: trace provider availability check", + test: () => { + // Act + const provider = this._ai.core.getTraceProvider(); + Assert.equal(this._ai.getTraceProvider(), provider, "Core and AI instance should return same trace provider"); + + // Assert + Assert.ok(provider !== null && provider !== undefined, "Trace provider should be available"); + + Assert.ok(isFunction(provider.createSpan), "Provider should have createSpan method"); + Assert.ok(isFunction(this._ai.getActiveSpan), "Provider should have activeSpan method"); + Assert.ok(isFunction(this._ai.setActiveSpan), "Provider should have setActiveSpan method"); + Assert.ok(isFunction(provider.getProviderId), "Provider should have getProviderId method"); + Assert.ok(isFunction(provider.isAvailable), "Provider should have isAvailable method"); + } + }); + + this.testCase({ + name: "Integration: trace provider isAvailable should reflect initialization state", + test: () => { + // Act + const provider = this._ai.core.getTraceProvider(); + + // Assert + const isAvailable = provider.isAvailable(); + Assert.ok(typeof isAvailable === 'boolean', "isAvailable should return boolean"); + Assert.ok(isAvailable, "Provider should be available after SDK initialization"); + } + }); + + this.testCase({ + name: "Integration: trace provider should have identifiable providerId", + test: () => { + // Act + const provider = this._ai.core.getTraceProvider(); + + // Assert + if (provider) { + const providerId = provider.getProviderId(); + Assert.ok(providerId, "Provider should have an ID"); + Assert.ok(typeof providerId === 'string', + "Provider ID should be a string"); + Assert.ok(providerId.length > 0, + "Provider ID should not be empty"); + } + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/TraceProvider.Tests.ts b/AISKU/Tests/Unit/src/TraceProvider.Tests.ts new file mode 100644 index 000000000..3e11d09aa --- /dev/null +++ b/AISKU/Tests/Unit/src/TraceProvider.Tests.ts @@ -0,0 +1,716 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { + IReadableSpan, IOTelSpanOptions, eOTelSpanKind, ITraceProvider, ITelemetryItem, + isFunction +} from "@microsoft/applicationinsights-core-js"; + +export class TraceProviderTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${TraceProviderTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "TraceProviderTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: TraceProviderTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize tests: ' + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addProviderAvailabilityTests(); + this.addGetProviderIdTests(); + this.addIsAvailableTests(); + this.addCreateSpanTests(); + this.addProviderIntegrationTests(); + } + + private addProviderAvailabilityTests(): void { + this.testCase({ + name: "TraceProvider: getTraceProvider should return provider instance", + test: () => { + // Act + const provider = this._ai.core.getTraceProvider(); + + // Assert + Assert.ok(provider !== null && provider !== undefined, + "Should return a trace provider instance"); + Assert.ok(typeof provider === 'object', + "Provider should be an object"); + } + }); + + this.testCase({ + name: "TraceProvider: provider should have all required methods", + test: () => { + // Act + const provider = this._ai.core.getTraceProvider(); + Assert.equal(this._ai.getTraceProvider(), provider, "Core and AI instance should return same trace provider"); + + // Assert + Assert.ok(provider, "Provider should exist"); + if (provider) { + Assert.ok(isFunction(provider.createSpan), "Should have createSpan method"); + Assert.ok(isFunction(this._ai.getActiveSpan), "Should have activeSpan method"); + Assert.ok(isFunction(this._ai.setActiveSpan), "Should have setActiveSpan method"); + Assert.ok(isFunction(provider.getProviderId), "Should have getProviderId method"); + Assert.ok(isFunction(provider.isAvailable), "Should have isAvailable method"); + } + } + }); + + this.testCase({ + name: "TraceProvider: provider should be available after SDK initialization", + test: () => { + // Act + const provider = this._ai.core.getTraceProvider(); + + // Assert + Assert.ok(provider !== null, "Provider should not be null"); + Assert.ok(provider !== undefined, "Provider should not be undefined"); + } + }); + + this.testCase({ + name: "TraceProvider: multiple calls to getTraceProvider should return same provider", + test: () => { + // Act + const provider1 = this._ai.core.getTraceProvider(); + const provider2 = this._ai.core.getTraceProvider(); + const provider3 = this._ai.core.getTraceProvider(); + + // Assert + Assert.ok(provider1 === provider2, + "First and second calls should return same provider"); + Assert.ok(provider2 === provider3, + "Second and third calls should return same provider"); + } + }); + } + + private addGetProviderIdTests(): void { + this.testCase({ + name: "getProviderId: should return a string identifier", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const providerId = provider?.getProviderId(); + + // Assert + Assert.ok(providerId !== null && providerId !== undefined, + "Provider ID should not be null or undefined"); + Assert.ok(typeof providerId === 'string', + "Provider ID should be a string"); + } + }); + + this.testCase({ + name: "getProviderId: should return non-empty string", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const providerId = provider?.getProviderId(); + + // Assert + if (providerId) { + Assert.ok(providerId.length > 0, + "Provider ID should not be empty"); + } + } + }); + + this.testCase({ + name: "getProviderId: should return consistent ID across multiple calls", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const providerId1 = provider?.getProviderId(); + const providerId2 = provider?.getProviderId(); + const providerId3 = provider?.getProviderId(); + + // Assert + Assert.equal(providerId1, providerId2, + "Provider ID should be consistent across calls"); + Assert.equal(providerId2, providerId3, + "Provider ID should be consistent across calls"); + } + }); + + this.testCase({ + name: "getProviderId: should return identifiable name", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const providerId = provider?.getProviderId(); + + // Assert + Assert.ok(providerId, "Provider ID should exist"); + if (providerId) { + // Provider ID should be a meaningful identifier, not just random characters + Assert.ok(providerId.length > 2, + "Provider ID should be more than 2 characters"); + } + } + }); + } + + private addIsAvailableTests(): void { + this.testCase({ + name: "isAvailable: should return boolean value", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const isAvailable = provider?.isAvailable(); + + // Assert + Assert.ok(typeof isAvailable === 'boolean', + "isAvailable should return a boolean"); + } + }); + + this.testCase({ + name: "isAvailable: should return true after SDK initialization", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const isAvailable = provider?.isAvailable(); + + // Assert + Assert.ok(isAvailable === true, + "Provider should be available after SDK initialization"); + } + }); + + this.testCase({ + name: "isAvailable: should be consistent across multiple calls", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const isAvailable1 = provider?.isAvailable(); + const isAvailable2 = provider?.isAvailable(); + const isAvailable3 = provider?.isAvailable(); + + // Assert + Assert.equal(isAvailable1, isAvailable2, + "Availability should be consistent"); + Assert.equal(isAvailable2, isAvailable3, + "Availability should be consistent"); + } + }); + + this.testCase({ + name: "isAvailable: available provider should allow span creation", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + const isAvailable = provider?.isAvailable(); + + // Act + let canCreateSpan = false; + if (provider && isAvailable) { + const span = provider.createSpan("availability-test-span"); + canCreateSpan = span !== null && span !== undefined; + span?.end(); + } + + // Assert + Assert.ok(isAvailable, "Provider should be available"); + Assert.ok(canCreateSpan, + "Available provider should allow span creation"); + } + }); + + this.testCase({ + name: "isAvailable: should reflect provider initialization state", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // After full SDK initialization, provider should be available + Assert.ok(provider !== null, "Provider should not be null"); + + // Act + const isAvailable = provider?.isAvailable(); + + Assert.ok(isAvailable !== undefined, "isAvailable should not be undefined"); + + // Assert + + Assert.ok(isFunction(provider.createSpan), "Available provider should have createSpan"); + Assert.ok(isFunction(provider.getProviderId), "Available provider should have getProviderId"); + Assert.ok(isFunction(provider.isAvailable), "Available provider should have isAvailable"); + Assert.ok(isFunction(this._ai.getActiveSpan), "Available provider should have activeSpan"); + Assert.ok(isFunction(this._ai.setActiveSpan), "Available provider should have setActiveSpan"); + Assert.ok(isFunction(this._ai.core.getActiveSpan), "Available core should have activeSpan"); + Assert.ok(isFunction(this._ai.core.setActiveSpan), "Available core should have setActiveSpan"); + } + }); + } + + private addCreateSpanTests(): void { + this.testCase({ + name: "Provider createSpan: should create valid span", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + const spanName = "provider-create-span-test"; + + // Act + let span: IReadableSpan | null = null; + if (provider) { + span = provider.createSpan(spanName); + } + + // Assert + Assert.ok(span !== null && span !== undefined, + "Provider should create a span"); + if (span) { + Assert.equal(span.name, spanName, "Span name should match"); + Assert.ok(typeof span.isRecording === 'function', + "Span should have isRecording method"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Provider createSpan: should create span with options", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + const spanName = "provider-span-with-options"; + const options: IOTelSpanOptions = { + kind: eOTelSpanKind.CLIENT, + attributes: { + "test.attribute": "value" + } + }; + + // Act + let span: IReadableSpan | null = null; + if (provider) { + span = provider.createSpan(spanName, options); + } + + // Assert + Assert.ok(span !== null, "Provider should create span with options"); + if (span) { + Assert.equal(span.kind, eOTelSpanKind.CLIENT, + "Span kind should match options"); + Assert.ok(span.attributes["test.attribute"] === "value", + "Span attributes should be set"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Provider createSpan: should create span with parent context", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + let parentSpan: IReadableSpan | null = null; + let childSpan: IReadableSpan | null = null; + + if (provider) { + parentSpan = provider.createSpan("parent-span"); + const parentCtx = parentSpan.spanContext(); + + // Act + childSpan = provider.createSpan("child-span", undefined, parentCtx); + + // Assert + Assert.ok(childSpan !== null, "Child span should be created"); + if (childSpan && parentSpan) { + const childCtx = childSpan.spanContext(); + Assert.equal(childCtx.traceId, parentCtx.traceId, + "Child should inherit parent traceId"); + Assert.notEqual(childCtx.spanId, parentCtx.spanId, + "Child should have different spanId"); + } + } + + // Cleanup + childSpan?.end(); + parentSpan?.end(); + } + }); + + this.testCase({ + name: "Provider createSpan: should create multiple independent spans", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + let span1: IReadableSpan | null = null; + let span2: IReadableSpan | null = null; + let span3: IReadableSpan | null = null; + + if (provider) { + span1 = provider.createSpan("span-1"); + span2 = provider.createSpan("span-2"); + span3 = provider.createSpan("span-3"); + } + + // Assert + Assert.ok(span1 !== null, "First span should be created"); + Assert.ok(span2 !== null, "Second span should be created"); + Assert.ok(span3 !== null, "Third span should be created"); + + if (span1 && span2 && span3) { + const ctx1 = span1.spanContext(); + const ctx2 = span2.spanContext(); + const ctx3 = span3.spanContext(); + + Assert.notEqual(ctx1.spanId, ctx2.spanId, + "Spans should have different spanIds"); + Assert.notEqual(ctx2.spanId, ctx3.spanId, + "Spans should have different spanIds"); + Assert.notEqual(ctx1.spanId, ctx3.spanId, + "Spans should have different spanIds"); + } + + // Cleanup + span1?.end(); + span2?.end(); + span3?.end(); + } + }); + + this.testCase({ + name: "Provider createSpan: should create recording spans by default", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + let span: IReadableSpan | null = null; + if (provider) { + span = provider.createSpan("recording-test"); + } + + // Assert + Assert.ok(span !== null, "Span should be created"); + if (span) { + Assert.ok(span.isRecording(), + "Span should be recording by default"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Provider createSpan: should respect recording option when false", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + const options: IOTelSpanOptions = { + recording: false + }; + + // Act + let span: IReadableSpan | null = null; + if (provider) { + span = provider.createSpan("non-recording-test", options); + } + + // Assert + Assert.ok(span !== null, "Span should be created"); + if (span) { + Assert.ok(!span.isRecording(), + "Span should not be recording when options.recording is false"); + } + + // Cleanup + span?.end(); + } + }); + } + + private addProviderIntegrationTests(): void { + this.testCase({ + name: "Integration: provider operations should work with SDK instance", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act - Create span via provider + let providerSpan: IReadableSpan | null = null; + if (provider) { + providerSpan = provider.createSpan("provider-integration-span"); + } + + // Create span via SDK + const sdkSpan = this._ai.startSpan("sdk-integration-span"); + + // Assert + Assert.ok(providerSpan !== null, + "Provider should create span successfully"); + Assert.ok(sdkSpan !== null, + "SDK should create span successfully"); + + if (providerSpan && sdkSpan) { + // Both spans should have valid contexts + const providerCtx = providerSpan.spanContext(); + const sdkCtx = sdkSpan.spanContext(); + + Assert.ok(providerCtx.traceId, "Provider span should have traceId"); + Assert.ok(providerCtx.spanId, "Provider span should have spanId"); + Assert.ok(sdkCtx.traceId, "SDK span should have traceId"); + Assert.ok(sdkCtx.spanId, "SDK span should have spanId"); + } + + // Cleanup + providerSpan?.end(); + sdkSpan?.end(); + } + }); + + this.testCase({ + name: "Integration: provider activeSpan and setActiveSpan work together", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + let span: IReadableSpan | null = null; + if (provider) { + span = provider.createSpan("active-integration-span"); + this._ai.setActiveSpan(span); + const activeSpan = this._ai.getActiveSpan(); + + // Assert + Assert.ok(activeSpan !== null, "Active span should be retrievable"); + Assert.equal(activeSpan.name, span.name, + "Active span should match the set span"); + Assert.equal(activeSpan, this._ai.core.getActiveSpan(), "Active span from core should match active span from SDK"); + } + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Integration: provider availability affects span creation", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const isAvailable = provider?.isAvailable(); + let canCreateSpan = false; + + if (provider) { + try { + const span = provider.createSpan("availability-integration-test"); + canCreateSpan = span !== null; + span?.end(); + } catch (e) { + canCreateSpan = false; + } + } + + // Assert + if (isAvailable) { + Assert.ok(canCreateSpan, + "Available provider should successfully create spans"); + } else { + // If provider is not available, we should handle it gracefully + Assert.ok(!canCreateSpan || canCreateSpan, + "Provider availability state should be consistent with span creation"); + } + } + }); + + this.testCase({ + name: "Integration: provider ID is consistent with trace operations", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + const providerId = provider?.getProviderId(); + let span: IReadableSpan | null = null; + + if (provider) { + span = provider.createSpan("provider-id-integration"); + } + + // Assert + Assert.ok(providerId, "Provider should have an ID"); + Assert.ok(span !== null, + "Provider with ID should be able to create spans"); + + // Cleanup + span?.end(); + } + }); + + this.testCase({ + name: "Integration: provider methods are callable without errors", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act & Assert - All methods should be callable + Assert.ok(provider !== null, "Provider should exist"); + + if (provider) { + // Test getProviderId + Assert.doesNotThrow(() => { + const id = provider.getProviderId(); + Assert.ok(typeof id === 'string', "getProviderId should return string"); + }, "getProviderId should not throw"); + + // Test isAvailable + Assert.doesNotThrow(() => { + const available = provider.isAvailable(); + Assert.ok(typeof available === 'boolean', + "isAvailable should return boolean"); + }, "isAvailable should not throw"); + + // Test createSpan + Assert.doesNotThrow(() => { + const span = provider.createSpan("error-test-span"); + Assert.ok(span !== null, "createSpan should return span"); + span?.end(); + }, "createSpan should not throw"); + + // Test activeSpan + Assert.doesNotThrow(() => { + const active = this._ai.getActiveSpan(); + // Can be null, that's ok + Assert.ok(active === null || typeof active === 'object', + "activeSpan should return null or span object"); + }, "activeSpan should not throw"); + + const span = provider.createSpan("set-active-error-test"); + + // Test setActiveSpan + Assert.doesNotThrow(() => { + this._ai.setActiveSpan(span); + span?.end(); + }, "setActiveSpan should not throw"); + + // Test setActiveSpan + Assert.doesNotThrow(() => { + this._ai.setActiveSpan(span); + }, "setActiveSpan should not throw when the span has already ended"); + + // Test setActiveSpan + Assert.doesNotThrow(() => { + span?.end(); + }, "ending an already ended span should not throw"); + } + } + }); + + this.testCase({ + name: "Integration: provider supports root span creation", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + + // Act + let rootSpan: IReadableSpan | null = null; + if (provider) { + rootSpan = provider.createSpan("root-span", { root: true }); + } + + // Assert + Assert.ok(rootSpan !== null, "Root span should be created"); + if (rootSpan) { + const ctx = rootSpan.spanContext(); + Assert.ok(ctx.traceId, "Root span should have traceId"); + Assert.ok(ctx.spanId, "Root span should have spanId"); + } + + // Cleanup + rootSpan?.end(); + } + }); + + this.testCase({ + name: "Integration: provider supports different span kinds", + test: () => { + // Arrange + const provider = this._ai.core.getTraceProvider(); + const spanKinds = [ + eOTelSpanKind.INTERNAL, + eOTelSpanKind.SERVER, + eOTelSpanKind.CLIENT, + eOTelSpanKind.PRODUCER, + eOTelSpanKind.CONSUMER + ]; + + // Act & Assert + if (provider) { + spanKinds.forEach(kind => { + const span = provider.createSpan(`span-kind-${kind}`, { kind }); + Assert.ok(span !== null, `Span with kind ${kind} should be created`); + Assert.equal(span?.kind, kind, + `Span should have kind ${kind}`); + span?.end(); + }); + } + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/TraceSuppression.Tests.ts b/AISKU/Tests/Unit/src/TraceSuppression.Tests.ts new file mode 100644 index 000000000..820f5a08a --- /dev/null +++ b/AISKU/Tests/Unit/src/TraceSuppression.Tests.ts @@ -0,0 +1,706 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/applicationinsights-web"; +import { eOTelSpanKind, ITelemetryItem, suppressTracing, unsuppressTracing, isTracingSuppressed } from "@microsoft/applicationinsights-core-js"; + + +function _createAndInitializeSDK(connectionString: string): ApplicationInsights { + let newInst = new ApplicationInsights({ + config: { + connectionString: connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + // Initialize the SDK + newInst.loadAppInsights(); + + return newInst +} + +export class TraceSuppressionTests extends AITestClass { + private static readonly _instrumentationKey = "b7170927-2d1c-44f1-acec-59f4e1751c11"; + private static readonly _connectionString = `InstrumentationKey=${TraceSuppressionTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + + // Track calls to track for validation + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "TraceSuppressionTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + this._ai = _createAndInitializeSDK(TraceSuppressionTests._connectionString); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error("Failed to initialize TraceSuppressionTests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addTests(); + } + + private addTests(): void { + + this.testCase({ + name: "TraceSuppression: new SDK instance should have tracing enabled by default and state should not leak between instances", + test: () => { + // Step 1: Verify first instance has tracing enabled by default + Assert.ok(!isTracingSuppressed(this._ai.core), "Instance 1: Tracing should NOT be suppressed in new instance"); + Assert.ok(!isTracingSuppressed(this._ai.core.config), "Instance 1: Tracing should NOT be suppressed on config"); + Assert.ok(!isTracingSuppressed(this._ai.otelApi), "Instance 1: Tracing should NOT be suppressed on otelApi"); + + // Verify that spans can record by default + const span1 = this._ai.startSpan("default-span"); + Assert.ok(span1, "Instance 1: Span should be created"); + Assert.ok(span1!.isRecording(), "Instance 1: Span should be recording by default"); + span1!.end(); + Assert.equal(this._trackCalls.length, 1, "Instance 1: Telemetry should be tracked by default"); + + // Step 2: Suppress tracing on this instance + suppressTracing(this._ai.core); + Assert.ok(isTracingSuppressed(this._ai.core), "Instance 1: Tracing should be suppressed after suppressTracing()"); + + // Verify suppression works - span still reports isRecording()=true but doesn't send telemetry + const span2 = this._ai.startSpan("suppressed-span"); + Assert.ok(!span2!.isRecording(), "Instance 2: Span reports isRecording()=false when suppressed"); + span2!.end(); + Assert.equal(this._trackCalls.length, 1, "Instance 1: No additional telemetry when suppressed"); + + // Step 3: Clean up first instance and create a new instance + this._ai.unload(false); + + this._ai = _createAndInitializeSDK(TraceSuppressionTests._connectionString); + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + // Step 4: Verify new instance has tracing enabled by default (not inheriting suppressed state) + Assert.ok(!isTracingSuppressed(this._ai.core), "Instance 2: Tracing should NOT be suppressed in new instance"); + Assert.ok(!isTracingSuppressed(this._ai.core.config), "Instance 2: Tracing should NOT be suppressed on config"); + Assert.ok(!isTracingSuppressed(this._ai.otelApi), "Instance 2: Tracing should NOT be suppressed on otelApi"); + + // Verify that spans can record in the new instance + const span3 = this._ai.startSpan("new-instance-span"); + Assert.ok(span3, "Instance 2: Span should be created"); + Assert.ok(span3!.isRecording(), "Instance 2: Span should be recording by default (state should not leak)"); + span3!.end(); + Assert.equal(this._trackCalls.length, 2, "Instance 2: Telemetry should be tracked in new instance"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppressTracing should be available as exported function", + test: () => { + // Verify that suppressTracing functions are available as imports + Assert.ok(typeof suppressTracing === "function", "suppressTracing should be available as exported function"); + Assert.ok(typeof unsuppressTracing === "function", "unsuppressTracing should be available as exported function"); + Assert.ok(typeof isTracingSuppressed === "function", "isTracingSuppressed should be available as exported function"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppressTracing on core should prevent span recording", + test: () => { + // Arrange + this._trackCalls = []; + Assert.ok(!isTracingSuppressed(this._ai.core), "Tracing should not be suppressed initially"); + + // Act - suppress tracing + suppressTracing(this._ai.core); + Assert.ok(isTracingSuppressed(this._ai.core), "Tracing should be suppressed after calling suppressTracing"); + + // Create span while tracing is suppressed + const span = this._ai.startSpan("suppressed-span", { + kind: eOTelSpanKind.INTERNAL, + attributes: { + "test.suppressed": true + } + }); + + // Assert + Assert.ok(span, "Span should still be created"); + Assert.ok(!span!.isRecording(), "Span reports isRecording()=false"); + + // End the span - should not generate telemetry + span!.end(); + + Assert.equal(this._trackCalls.length, 0, "No telemetry should be tracked when tracing is suppressed"); + } + }); + + this.testCase({ + name: "TraceSuppression: unsuppressTracing should restore span recording", + test: () => { + // Arrange + this._trackCalls = []; + suppressTracing(this._ai.core); + Assert.ok(isTracingSuppressed(this._ai.core), "Tracing should be suppressed"); + + // Create span while suppressed - still reports isRecording()=true but won't send telemetry + const suppressedSpan = this._ai.startSpan("suppressed-span"); + Assert.ok(!suppressedSpan!.isRecording(), "Span reports isRecording()=false even when suppressed"); + suppressedSpan!.end(); + + // Act - unsuppress tracing + unsuppressTracing(this._ai.core); + Assert.ok(!isTracingSuppressed(this._ai.core), "Tracing should not be suppressed after unsuppressTracing"); + + // Create new span after unsuppressing + const recordingSpan = this._ai.startSpan("recording-span", { + attributes: { + "test.recording": true + } + }); + + // Assert + Assert.ok(recordingSpan, "Span should be created"); + Assert.ok(recordingSpan!.isRecording(), "Span should be recording after unsuppressing"); + + // End the span - should generate telemetry + recordingSpan!.end(); + + Assert.equal(this._trackCalls.length, 1, "Telemetry should be tracked after unsuppressing"); + Assert.equal(this._trackCalls[0].baseData?.name, "recording-span", "Tracked span should have correct name"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppressTracing on config should prevent span recording", + test: () => { + // Arrange + this._trackCalls = []; + + // Suppress via config object + suppressTracing(this._ai.core.config); + Assert.ok(isTracingSuppressed(this._ai.core.config), "Tracing should be suppressed on config"); + Assert.ok(isTracingSuppressed(this._ai.core), "Tracing should be suppressed on core"); + + // Act - create span + const span = this._ai.startSpan("config-suppressed-span"); + + // Assert + Assert.ok(span, "Span should be created"); + Assert.ok(!span!.isRecording(), "Span reports isRecording()=false"); + span!.end(); + + Assert.equal(this._trackCalls.length, 0, "No telemetry when suppressed via config"); + } + }); + + this.testCase({ + name: "TraceSuppression: multiple startSpan calls while suppressed should all create non-recording spans", + test: () => { + // Arrange + this._trackCalls = []; + suppressTracing(this._ai.core); + + // Act - create multiple spans + const span1 = this._ai.startSpan("span-1"); + const span2 = this._ai.startSpan("span-2", { kind: eOTelSpanKind.CLIENT }); + const span3 = this._ai.startSpan("span-3", { kind: eOTelSpanKind.SERVER }); + + // Assert - spans still report isRecording()=true, suppression only affects telemetry output + Assert.ok(!span1!.isRecording(), "Span 1 reports isRecording()=false"); + Assert.ok(!span2!.isRecording(), "Span 2 reports isRecording()=false"); + Assert.ok(!span3!.isRecording(), "Span 3 reports isRecording()=false"); + + // All spans should still be valid and support operations + span1!.setAttribute("test", "value1"); + span2!.setStatus({ code: 0 }); + span3!.updateName("updated-span-3"); + + span1!.end(); + span2!.end(); + span3!.end(); + + Assert.equal(this._trackCalls.length, 0, "No telemetry should be generated for any suppressed span"); + } + }); + + this.testCase({ + name: "TraceSuppression: parent-child span hierarchy with suppression", + test: () => { + // Arrange + this._trackCalls = []; + suppressTracing(this._ai.core); + + // Act - create parent and child spans while suppressed + const parentSpan = this._ai.startSpan("parent-span", { + kind: eOTelSpanKind.SERVER + }); + Assert.ok(!parentSpan!.isRecording(), "Parent span reports isRecording()=false"); + + const childSpan = this._ai.startSpan("child-span", { + kind: eOTelSpanKind.INTERNAL + }); + Assert.ok(!childSpan!.isRecording(), "Child span reports isRecording()=false"); + + // Verify parent-child relationship still established + const childContext = childSpan!.spanContext(); + const parentContext = parentSpan!.spanContext(); + Assert.equal(childContext.traceId, parentContext.traceId, "Child should share traceId with parent"); + Assert.notEqual(childContext.spanId, parentContext.spanId, "Child should have different spanId"); + + childSpan!.end(); + parentSpan!.end(); + + Assert.equal(this._trackCalls.length, 0, "No telemetry for suppressed hierarchy"); + } + }); + + this.testCase({ + name: "TraceSuppression: toggle suppression during span lifecycle", + test: () => { + // Arrange + this._trackCalls = []; + + // Create recording span + const span1 = this._ai.startSpan("recording-span"); + Assert.ok(span1!.isRecording(), "Span should be recording initially"); + + // Suppress tracing mid-lifecycle + suppressTracing(this._ai.core); + + // Create new span while suppressed + const span2 = this._ai.startSpan("suppressed-span"); + Assert.ok(!span2!.isRecording(), "Span reports isRecording()=false when suppressed"); + + // End both spans + span1!.end(); // Was recording but tracing has been suppressed before it ends + span2!.end(); // Was not recording + + // Verify telemetry + Assert.equal(this._trackCalls.length, 0, "Only the recording span should generate telemetry"); + + // Unsuppress and create another span + unsuppressTracing(this._ai.core); + const span3 = this._ai.startSpan("restored-span"); + Assert.ok(span3!.isRecording(), "New span should be recording after unsuppressing"); + span3!.end(); + + Assert.equal(this._trackCalls.length, 1, "Restored span should generate telemetry"); + } + }); + + this.testCase({ + name: "TraceSuppression: toggle suppression during span lifecycle", + test: () => { + // Arrange + this._trackCalls = []; + + // Create recording span + const span1 = this._ai.startSpan("recording-span"); + Assert.ok(span1!.isRecording(), "Span should be recording initially"); + + // Suppress tracing mid-lifecycle + suppressTracing(this._ai.core); + + // Create new span while suppressed + const span2 = this._ai.startSpan("suppressed-span"); + Assert.ok(!span2!.isRecording(), "Span reports isRecording()=false when suppressed"); + + // Unsuppress and create another span + unsuppressTracing(this._ai.core); + + // End both spans + span1!.end(); // Was recording + span2!.end(); // Was not recording as tracing was suppressed when created + + // Verify telemetry + Assert.equal(this._trackCalls.length, 1, "Only the recording span should generate telemetry"); + Assert.equal(this._trackCalls[0].baseData?.name, "recording-span", "Recording span telemetry"); + + const span3 = this._ai.startSpan("restored-span"); + Assert.ok(span3!.isRecording(), "New span should be recording after unsuppressing"); + span3!.end(); + + Assert.equal(this._trackCalls.length, 2, "Restored span should generate telemetry"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppressTracing should affect all span kinds", + test: () => { + // Arrange + this._trackCalls = []; + suppressTracing(this._ai.core); + + // Act - create spans of all kinds + const internalSpan = this._ai.startSpan("internal", { kind: eOTelSpanKind.INTERNAL }); + const clientSpan = this._ai.startSpan("client", { kind: eOTelSpanKind.CLIENT }); + const serverSpan = this._ai.startSpan("server", { kind: eOTelSpanKind.SERVER }); + const producerSpan = this._ai.startSpan("producer", { kind: eOTelSpanKind.PRODUCER }); + const consumerSpan = this._ai.startSpan("consumer", { kind: eOTelSpanKind.CONSUMER }); + + // Assert - all spans still report isRecording()=true, suppression only prevents telemetry output + Assert.ok(!internalSpan!.isRecording(), "INTERNAL span reports isRecording()=false"); + Assert.ok(!clientSpan!.isRecording(), "CLIENT span reports isRecording()=false"); + Assert.ok(!serverSpan!.isRecording(), "SERVER span reports isRecording()=false"); + Assert.ok(!producerSpan!.isRecording(), "PRODUCER span reports isRecording()=false"); + Assert.ok(!consumerSpan!.isRecording(), "CONSUMER span reports isRecording()=false"); + + // End all spans + internalSpan!.end(); + clientSpan!.end(); + serverSpan!.end(); + producerSpan!.end(); + consumerSpan!.end(); + + Assert.equal(this._trackCalls.length, 0, "No telemetry for any span kind when suppressed"); + } + }); + + this.testCase({ + name: "TraceSuppression: span operations should still work when tracing is suppressed", + test: () => { + // Arrange + suppressTracing(this._ai.core); + const span = this._ai.startSpan("suppressed-span"); + Assert.ok(!span!.isRecording(), "Span reports isRecording()=false"); + + // Act - perform various span operations + span!.setAttribute("string-attr", "value"); + span!.setAttribute("number-attr", 42); + span!.setAttribute("boolean-attr", true); + + span!.setAttributes({ + "batch-1": "test1", + "batch-2": 123 + }); + + span!.setStatus({ + code: 0, + message: "Test status" + }); + + span!.updateName("updated-name"); + + span!.recordException(new Error("Test exception")); + + // Assert - operations should not throw + Assert.ok(true, "All operations completed without throwing"); + + // Verify span properties + Assert.equal(span!.name, "updated-name", "Name should be updated"); + Assert.ok(!span!.ended, "Span should not be ended yet"); + + span!.end(); + Assert.ok(span!.ended, "Span should be ended"); + } + }); + + this.testCase({ + name: "TraceSuppression: isTracingSuppressed should return false when not suppressed", + test: () => { + // Ensure no suppression + unsuppressTracing(this._ai.core); + + // Assert + Assert.ok(!isTracingSuppressed(this._ai.core), "Should return false when not suppressed"); + Assert.ok(!isTracingSuppressed(this._ai.core.config), "Config should also not be suppressed"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppressTracing should return the same context", + test: () => { + // Act + const returnedCore = suppressTracing(this._ai.core); + const returnedConfig = suppressTracing(this._ai.core.config); + + // Assert + Assert.equal(returnedCore, this._ai.core, "suppressTracing should return the same core instance"); + Assert.equal(returnedConfig, this._ai.core.config, "suppressTracing should return the same config instance"); + Assert.ok(isTracingSuppressed(returnedCore), "Returned core should have suppression enabled"); + Assert.ok(isTracingSuppressed(returnedConfig), "Returned config should have suppression enabled"); + } + }); + + this.testCase({ + name: "TraceSuppression: unsuppressTracing should return the same context", + test: () => { + // Arrange + suppressTracing(this._ai.core); + + // Act + const returnedCore = unsuppressTracing(this._ai.core); + const returnedConfig = unsuppressTracing(this._ai.core.config); + + // Assert + Assert.equal(returnedCore, this._ai.core, "unsuppressTracing should return the same core instance"); + Assert.equal(returnedConfig, this._ai.core.config, "unsuppressTracing should return the same config instance"); + Assert.ok(!isTracingSuppressed(returnedCore), "Returned core should have suppression disabled"); + Assert.ok(!isTracingSuppressed(returnedConfig), "Returned config should have suppression disabled"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppression state should persist across multiple checks", + test: () => { + // Initial state + Assert.ok(!isTracingSuppressed(this._ai.core), "Initially not suppressed"); + + // Suppress + suppressTracing(this._ai.core); + Assert.ok(isTracingSuppressed(this._ai.core), "Should be suppressed - check 1"); + Assert.ok(isTracingSuppressed(this._ai.core), "Should be suppressed - check 2"); + Assert.ok(isTracingSuppressed(this._ai.core), "Should be suppressed - check 3"); + + // Unsuppress + unsuppressTracing(this._ai.core); + Assert.ok(!isTracingSuppressed(this._ai.core), "Should not be suppressed - check 1"); + Assert.ok(!isTracingSuppressed(this._ai.core), "Should not be suppressed - check 2"); + Assert.ok(!isTracingSuppressed(this._ai.core), "Should not be suppressed - check 3"); + } + }); + + this.testCase({ + name: "TraceSuppression: span attributes should be preserved when tracing is suppressed", + test: () => { + // Arrange + this._trackCalls = []; + suppressTracing(this._ai.core); + + // Act - create span with attributes + const span = this._ai.startSpan("suppressed-with-attrs", { + attributes: { + "initial.attr1": "value1", + "initial.attr2": 100 + } + }); + + Assert.ok(!span!.isRecording(), "Span reports isRecording()=false"); + + // Add more attributes + span!.setAttribute("runtime.attr", "added-later"); + + // Assert - attributes should still be accessible + const attributes = (span as any).attributes || {}; + Assert.ok(attributes["initial.attr1"] === undefined || attributes["runtime.attr"] === undefined, + "Attributes should not be stored as span was not recording"); + + span!.end(); + Assert.equal(this._trackCalls.length, 0, "No telemetry should be generated"); + } + }); + + this.testCase({ + name: "TraceSuppression: span context should be valid when tracing is suppressed", + test: () => { + // Arrange + suppressTracing(this._ai.core); + + // Act + const span = this._ai.startSpan("suppressed-context-test"); + Assert.ok(!span!.isRecording(), "Span reports isRecording()=false"); + + // Assert - span context should be valid + const spanContext = span!.spanContext(); + Assert.ok(spanContext, "Span context should exist"); + Assert.ok(spanContext.traceId, "Trace ID should exist"); + Assert.ok(spanContext.spanId, "Span ID should exist"); + Assert.equal(spanContext.traceId.length, 32, "Trace ID should be 32 hex characters"); + Assert.equal(spanContext.spanId.length, 16, "Span ID should be 16 hex characters"); + + span!.end(); + } + }); + + this.testCase({ + name: "TraceSuppression: rapid suppression toggling should work correctly", + test: () => { + // Arrange + this._trackCalls = []; + + // Act - rapidly toggle suppression + for (let i = 0; i < 5; i++) { + suppressTracing(this._ai.core); + Assert.ok(isTracingSuppressed(this._ai.core), `Should be suppressed on iteration ${i}`); + + unsuppressTracing(this._ai.core); + Assert.ok(!isTracingSuppressed(this._ai.core), `Should not be suppressed on iteration ${i}`); + } + + // Final state check + Assert.ok(!isTracingSuppressed(this._ai.core), "Should end in unsuppressed state"); + + // Create a recording span + const span = this._ai.startSpan("final-span"); + Assert.ok(span!.isRecording(), "Span should be recording after toggles"); + span!.end(); + + Assert.equal(this._trackCalls.length, 1, "Telemetry should be tracked"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppression should work with explicit parent context", + test: () => { + // Arrange + this._trackCalls = []; + + // Create a recording parent span first + const parentSpan = this._ai.startSpan("parent-recording"); + Assert.ok(parentSpan!.isRecording(), "Parent should be recording"); + const parentContext = this._ai.getTraceCtx(); + + // Suppress tracing + suppressTracing(this._ai.core); + + // Act - create child with explicit parent while suppressed + const childSpan = this._ai.startSpan("child-suppressed", { + kind: eOTelSpanKind.INTERNAL + }, parentContext); + + // Assert + Assert.ok(!childSpan!.isRecording(), "Child span reports isRecording()=false when suppressed"); + + const childContext = childSpan!.spanContext(); + Assert.equal(childContext.traceId, parentContext!.traceId, "Child should have same traceId as parent"); + + unsuppressTracing(this._ai.core); + + childSpan!.end(); + parentSpan!.end(); + + // Only parent should generate telemetry + Assert.equal(this._trackCalls.length, 1, "Only parent span should generate telemetry"); + Assert.equal(this._trackCalls[0].baseData?.name, "parent-recording", "Parent span telemetry"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppression should work even with parent context", + test: () => { + // Arrange + this._trackCalls = []; + + // Create a recording parent span first + const parentSpan = this._ai.startSpan("parent-recording"); + Assert.ok(parentSpan!.isRecording(), "Parent should be recording"); + const parentContext = this._ai.getTraceCtx(); + + // Suppress tracing + suppressTracing(this._ai.core); + + // Act - create child with explicit parent while suppressed + const childSpan = this._ai.startSpan("child-suppressed", { + kind: eOTelSpanKind.INTERNAL + }, parentContext); + + // Assert + Assert.ok(!childSpan!.isRecording(), "Child span reports isRecording()=false when suppressed"); + + const childContext = childSpan!.spanContext(); + Assert.equal(childContext.traceId, parentContext!.traceId, "Child should have same traceId as parent"); + + childSpan!.end(); + parentSpan!.end(); + + // Only parent should generate telemetry + Assert.equal(this._trackCalls.length, 0, "Parent span should not generate telemetry either as suppression is active"); + } + }); + + this.testCase({ + name: "TraceSuppression: isTracingSuppressed should handle null/undefined gracefully", + test: () => { + // Act & Assert - should not throw + let result1: boolean; + let result2: boolean; + + try { + result1 = isTracingSuppressed(null as any); + result2 = isTracingSuppressed(undefined as any); + Assert.ok(true, "isTracingSuppressed should handle null/undefined without throwing"); + Assert.ok(!result1, "Should return false for null"); + Assert.ok(!result2, "Should return false for undefined"); + } catch (e) { + Assert.ok(false, "isTracingSuppressed should not throw for null/undefined"); + } + } + }); + + this.testCase({ + name: "TraceSuppression: suppressTracing with startSpan integration test", + test: () => { + // Arrange + this._trackCalls = []; + + // Test 1: Normal recording + const span1 = this._ai.startSpan("normal-1"); + Assert.ok(span1!.isRecording(), "Span 1 should be recording"); + span1!.end(); + Assert.equal(this._trackCalls.length, 1, "Should have 1 telemetry item"); + + // Test 2: Suppress and verify spans still report isRecording()=true but don't send telemetry + suppressTracing(this._ai.core); + const span2 = this._ai.startSpan("suppressed-1"); + const span3 = this._ai.startSpan("suppressed-2"); + Assert.ok(!span2!.isRecording(), "Span 2 reports isRecording()=false when suppressed"); + Assert.ok(!span3!.isRecording(), "Span 3 reports isRecording()=false when suppressed"); + span2!.end(); + span3!.end(); + Assert.equal(this._trackCalls.length, 1, "Should still have only 1 telemetry item"); + + // Test 3: Unsuppress and verify startSpan creates recording spans again + unsuppressTracing(this._ai.core); + const span4 = this._ai.startSpan("normal-2"); + Assert.ok(span4!.isRecording(), "Span 4 should be recording"); + span4!.end(); + Assert.equal(this._trackCalls.length, 2, "Should have 2 telemetry items"); + + // Verify telemetry content + Assert.equal(this._trackCalls[0].baseData?.name, "normal-1", "First telemetry is from span1"); + Assert.equal(this._trackCalls[1].baseData?.name, "normal-2", "Second telemetry is from span4"); + } + }); + + this.testCase({ + name: "TraceSuppression: suppression with nested spans", + test: () => { + // Arrange + this._trackCalls = []; + suppressTracing(this._ai.core); + + // Create and set active span manually + const span1 = this._ai.startSpan("outer-span"); + Assert.ok(!span1!.isRecording(), "Outer span reports isRecording()=false"); + + // Simulate nested operation + const span2 = this._ai.startSpan("inner-span"); + Assert.ok(!span2!.isRecording(), "Inner span reports isRecording()=false"); + span2!.end(); + span1!.end(); + + Assert.equal(this._trackCalls.length, 0, "No telemetry for suppressed nested spans"); + } + }); + } +} diff --git a/AISKU/Tests/Unit/src/UseSpan.Tests.ts b/AISKU/Tests/Unit/src/UseSpan.Tests.ts new file mode 100644 index 000000000..b036bb478 --- /dev/null +++ b/AISKU/Tests/Unit/src/UseSpan.Tests.ts @@ -0,0 +1,1165 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/applicationinsights-web"; +import { + IReadableSpan, eOTelSpanKind, eOTelSpanStatusCode, useSpan, ITelemetryItem, ISpanScope, ITraceHost +} from "@microsoft/applicationinsights-core-js"; +import { IAppInsightsCore } from "@microsoft/applicationinsights-core-js/src/applicationinsights-core-js"; + +export class UseSpanTests extends AITestClass { + private static readonly _instrumentationKey = "b7170927-2d1c-44f1-acec-59f4e1751c11"; + private static readonly _connectionString = `InstrumentationKey=${UseSpanTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + + // Track calls to track for validation + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "UseSpanTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: UseSpanTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + // Initialize the SDK + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error("Failed to initialize UseSpan tests: " + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addTests(); + } + + private addTests(): void { + + this.testCase({ + name: "UseSpan: useSpan should be available as exported function", + test: () => { + // Verify that useSpan is available as an import + Assert.ok(typeof useSpan === "function", "useSpan should be available as exported function"); + } + }); + + this.testCase({ + name: "UseSpan: should execute function within span context", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-context-test", { + kind: eOTelSpanKind.SERVER, + attributes: { + "test.type": "context-execution" + } + }); + + Assert.ok(testSpan, "Test span should be created"); + Assert.ok(this._ai.core, "Core should be available"); + + let capturedActiveSpan: IReadableSpan | null = null; + let capturedHost: ITraceHost | null = null; + const testFunction = function(this: ISpanScope) { + capturedActiveSpan = this.host.getActiveSpan(); + capturedHost = this.host; + return "context-success"; + }; + + // Act + const result = useSpan(this._ai.core!, testSpan!, testFunction); + + // Assert + Assert.equal(result, "context-success", "Function should execute and return result"); + Assert.ok(capturedActiveSpan, "Function should have access to active span"); + Assert.equal(capturedActiveSpan, testSpan, "Active span should be the provided test span"); + Assert.equal(capturedHost, this._ai.core, "Active host should be the core instance (passed to useSpan)"); + } + }); + + this.testCase({ + name: "UseSpan: should work with telemetry tracking inside span context", + test: () => { + // Arrange + this._trackCalls = []; + const testSpan = this._ai.startSpan("useSpan-telemetry-test", { + attributes: { + "operation.name": "telemetry-tracking" + } + }); + + Assert.ok(testSpan, "Test span should be created"); + + const telemetryFunction = () => { + // Track some telemetry within the span context + this._ai.trackEvent({ + name: "operation-event", + properties: { + "event.source": "useSpan-context" + } + }); + + this._ai.trackMetric({ + name: "operation.duration", + average: 123.45 + }); + + return "telemetry-tracked"; + }; + + // Act + const result = useSpan(this._ai.core!, testSpan!, telemetryFunction); + + // Assert + Assert.equal(result, "telemetry-tracked", "Function should complete successfully"); + + // End the span to trigger trace generation + testSpan!.end(); + + // Verify track was called for the span + Assert.equal(this._trackCalls.length, 3, "Should have one track call from span ending"); + const item = this._trackCalls[2]; + Assert.ok(item.baseData && item.baseData.properties, "Item should have properties"); + Assert.equal("useSpan-telemetry-test", item.baseData.name, "Should include span name in properties"); + } + }); + + this.testCase({ + name: "UseSpan: should handle complex function arguments and return values", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-arguments-test"); + Assert.ok(testSpan, "Test span should be created"); + + const complexFunction = ( + _scope: ISpanScope, + stringArg: string, + numberArg: number, + objectArg: { key: string; value: number }, + arrayArg: string[] + ) => { + return { + processedString: stringArg!.toUpperCase(), + doubledNumber: numberArg! * 2, + extractedValue: objectArg!.value, + joinedArray: arrayArg!.join("-"), + timestamp: Date.now() + }; + }; + + const inputObject = { key: "test-key", value: 42 }; + const inputArray = ["item1", "item2", "item3"]; + + // Act + const result = useSpan( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._ai.core!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + testSpan!, + complexFunction, + undefined, + "hello world", + 10, + inputObject, + inputArray + ); + + // Assert + Assert.equal(result.processedString, "HELLO WORLD", "String should be processed correctly"); + Assert.equal(result.doubledNumber, 20, "Number should be doubled correctly"); + Assert.equal(result.extractedValue, 42, "Object value should be extracted correctly"); + Assert.equal(result.joinedArray, "item1-item2-item3", "Array should be joined correctly"); + Assert.ok(result.timestamp > 0, "Timestamp should be generated"); + } + }); + + this.testCase({ + name: "UseSpan: should handle function with this context binding", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-this-binding-test"); + Assert.ok(testSpan, "Test span should be created"); + + class TestService { + private _serviceId: string; + private _multiplier: number; + + constructor(id: string, multiplier: number) { + this._serviceId = id; + this._multiplier = multiplier; + } + + public processValue(_scope: ISpanScope, input: number): { serviceId: string; result: number; multiplied: number } { + return { + serviceId: this._serviceId, + result: input + 100, + multiplied: input * this._multiplier + }; + } + } + + const service = new TestService("test-service-123", 3); + + // Act + const result = useSpan( + this._ai.core!, + testSpan!, + service.processValue, + service, + 25 + ); + + // Assert + Assert.equal(result.serviceId, "test-service-123", "Service ID should be preserved via this binding"); + Assert.equal(result.result, 125, "Input should be processed correctly"); + Assert.equal(result.multiplied, 75, "Multiplication should use instance property"); + } + }); + + this.testCase({ + name: "UseSpan: should maintain span context across async-like operations", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-async-like-test", { + attributes: { + "operation.type": "async-simulation" + } + }); + Assert.ok(testSpan, "Test span should be created"); + + let spanDuringCallback: IReadableSpan | null = null; + let callbackExecuted = false; + + const asyncLikeFunction = (scope: ISpanScope, callback: (data: string) => void) => { + // Simulate async work that completes synchronously in test + let currentSpan = scope.span; + + // Simulate callback execution (would normally be async) + setTimeout(() => { + spanDuringCallback = this._ai.core!.getActiveSpan(); + callback("async-data"); + callbackExecuted = true; + }, 0); + + return currentSpan ? (currentSpan as IReadableSpan).name : "no-span"; + }; + + // Act + let callbackData = ""; + const callback = (data: string) => { + callbackData = data; + }; + + const result = useSpan(this._ai.core!, testSpan!, asyncLikeFunction, undefined, callback); + + // Assert + Assert.equal(result, "useSpan-async-like-test", "Function should have access to span name"); + + // Note: In a real async scenario, the span context wouldn't automatically + // propagate to the setTimeout callback without additional context management + // This test validates the synchronous behavior of useSpan + } + }); + + this.testCase({ + name: "UseSpan: should handle exceptions and preserve span operations", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-exception-test", { + attributes: { + "test.expects": "exception" + } + }); + Assert.ok(testSpan, "Test span should be created"); + + const exceptionFunction = () => { + // Perform some span operations before throwing + const activeSpan = this._ai.core!.getActiveSpan(); + Assert.ok(activeSpan, "Should have active span before exception"); + + activeSpan!.setAttribute("operation.status", "error"); + activeSpan!.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Operation failed with test exception" + }); + + throw new Error("Test exception for useSpan handling"); + }; + + // Act & Assert + let caughtException: Error | null = null; + try { + useSpan(this._ai.core!, testSpan!, exceptionFunction); + } catch (error) { + caughtException = error as Error; + } + + Assert.ok(caughtException, "Exception should be thrown and caught"); + Assert.equal(caughtException!.message, "Test exception for useSpan handling", "Exception message should be preserved"); + + // Verify span is still valid and operations were applied + Assert.ok(testSpan!.isRecording(), "Span should still be recording after exception"); + const readableSpan = testSpan! as IReadableSpan; + Assert.ok(!readableSpan.ended, "Span should not be ended by useSpan after exception"); + } + }); + + this.testCase({ + name: "UseSpan: should work with nested span operations and child spans", + test: () => { + // Arrange + this._trackCalls = []; + const parentSpan = this._ai.startSpan("parent-operation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "operation.name": "parent-process" + } + }); + Assert.ok(parentSpan, "Parent span should be created"); + + const nestedOperations = () => { + // Verify we have the parent span as active + const currentActive = this._ai.core!.getActiveSpan(); + Assert.equal(currentActive, parentSpan, "Parent span should be active"); + + // Create child operations within the parent span context + const childSpan1 = this._ai.startSpan("child-operation-1", { + attributes: { "child.order": 1 } + }); + childSpan1!.setAttribute("child.status", "completed"); + childSpan1!.end(); + + const childSpan2 = this._ai.startSpan("child-operation-2", { + attributes: { "child.order": 2 } + }); + childSpan2!.setAttribute("child.status", "completed"); + childSpan2!.end(); + + return "nested-operations-completed"; + }; + + // Act + const result = useSpan(this._ai.core!, parentSpan!, nestedOperations); + + // Assert + Assert.equal(result, "nested-operations-completed", "Nested operations should complete successfully"); + + // End parent span to generate telemetry + parentSpan!.end(); + + // Should have 3 telemetry items: parent + 2 children + Assert.equal(this._trackCalls.length, 3, "Should have telemetry for parent and child spans"); + + // Verify span names in properties + const spanNames = this._trackCalls.map(item => item.baseData?.name).filter(n => n); + Assert.ok(spanNames.some(name => name === "parent-operation"), "Should have parent span telemetry"); + Assert.ok(spanNames.some(name => name === "child-operation-1"), "Should have child-1 span telemetry"); + Assert.ok(spanNames.some(name => name === "child-operation-2"), "Should have child-2 span telemetry"); + } + }); + + this.testCase({ + name: "UseSpan: should support different return value types", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-return-types-test"); + Assert.ok(testSpan, "Test span should be created"); + + // Test various return types + const stringResult = useSpan(this._ai.core!, testSpan!, () => "string-result"); + const numberResult = useSpan(this._ai.core!, testSpan!, () => 42.5); + const booleanResult = useSpan(this._ai.core!, testSpan!, () => true); + const arrayResult = useSpan(this._ai.core!, testSpan!, () => [1, 2, 3]); + const objectResult = useSpan(this._ai.core!, testSpan!, () => ({ key: "value", nested: { prop: 123 } })); + const nullResult = useSpan(this._ai.core!, testSpan!, () => null); + const undefinedResult = useSpan(this._ai.core!, testSpan!, () => undefined); + + // Assert + Assert.equal(stringResult, "string-result", "String return should work"); + Assert.equal(numberResult, 42.5, "Number return should work"); + Assert.equal(booleanResult, true, "Boolean return should work"); + Assert.equal(arrayResult.length, 3, "Array return should work"); + Assert.equal(arrayResult[1], 2, "Array elements should be preserved"); + Assert.equal(objectResult.key, "value", "Object properties should be preserved"); + Assert.equal(objectResult.nested.prop, 123, "Nested object properties should be preserved"); + Assert.equal(nullResult, null, "Null return should work"); + Assert.equal(undefinedResult, undefined, "Undefined return should work"); + } + }); + + this.testCase({ + name: "UseSpan: should handle rapid successive calls efficiently", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-performance-test"); + Assert.ok(testSpan, "Test span should be created"); + + const iterations = 100; + let totalResult = 0; + + // Simple computation function + const computeFunction = (_scope: ISpanScope, input: number) => { + return input * 2 + 1; + }; + + const startTime = Date.now(); + + // Act - Multiple rapid useSpan calls + for (let i = 0; i < iterations; i++) { + const result = useSpan(this._ai.core!, testSpan!, computeFunction, undefined, i); + totalResult += result; + } + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Assert + const expectedTotal = Array.from({length: iterations}, (_, i) => i * 2 + 1).reduce((sum, val) => sum + val, 0); + Assert.equal(totalResult, expectedTotal, "All computations should be correct"); + + // Performance assertion - should complete reasonably quickly + Assert.ok(duration < 1000, `Performance test should complete quickly: ${duration}ms for ${iterations} iterations`); + + // Verify span is still valid after many operations + Assert.ok(testSpan!.isRecording(), "Span should still be recording after multiple useSpan calls"); + } + }); + + this.testCase({ + name: "UseSpan: should integrate with AI telemetry correlation", + test: () => { + // Arrange + this._trackCalls = []; + const operationSpan = this._ai.startSpan("user-operation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "user.id": "user-123", + "operation.type": "data-processing" + } + }); + Assert.ok(operationSpan, "Operation span should be created"); + + const businessLogicFunction = (_scope: ISpanScope, userId: string, dataType: string) => { + // Track multiple telemetry items within span context + this._ai.trackEvent({ + name: "data-processing-started", + properties: { + "user.id": userId, + "data.type": dataType, + "processing.stage": "initialization" + } + }); + + // Simulate some processing steps + for (let step = 1; step <= 3; step++) { + this._ai.trackMetric({ + name: "processing.step.duration", + average: step * 10.5, + properties: { + "step.number": step.toString() + } + }); + } + + this._ai.trackEvent({ + name: "data-processing-completed", + properties: { + "user.id": userId, + "data.type": dataType, + "processing.stage": "completion", + "steps.completed": "3" + } + }); + + return { + userId: userId, + dataType: dataType, + stepsCompleted: 3, + status: "success" + }; + }; + + // Act + const result = useSpan( + this._ai.core!, + operationSpan!, + businessLogicFunction, + undefined, + "user-123", + "customer-data" + ); + + // End the span to generate trace + operationSpan!.end(); + + // Assert + Assert.equal(result.userId, "user-123", "User ID should be processed correctly"); + Assert.equal(result.dataType, "customer-data", "Data type should be processed correctly"); + Assert.equal(result.stepsCompleted, 3, "All processing steps should be completed"); + Assert.equal(result.status, "success", "Operation should complete successfully"); + + // Verify span telemetry was generated + Assert.equal(this._trackCalls.length, 6, "Should have one track call from span ending"); + const spanItem = this._trackCalls[5]; + Assert.ok(spanItem.baseData && spanItem.baseData.properties, "Item should have properties"); + Assert.equal("user-operation", spanItem.baseData.name, "Should include span name"); + + // Verify span attributes are included in properties + Assert.equal(spanItem.baseData.properties["user.id"], "user-123", "Span attributes should be included in telemetry"); + Assert.equal(spanItem.baseData.properties["operation.type"], "data-processing", "All span attributes should be preserved"); + } + }); + + this.testCase({ + name: "UseSpan: should handle empty or no-op functions gracefully", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("useSpan-noop-test"); + Assert.ok(testSpan, "Test span should be created"); + + // Test empty function + const emptyFunction = () => {}; + + // Test function that just returns without doing anything + const noOpFunction = () => { + return; + }; + + // Test function that returns undefined explicitly + const undefinedFunction = () => { + return undefined; + }; + + // Act + const emptyResult = useSpan(this._ai.core!, testSpan!, emptyFunction); + const noOpResult = useSpan(this._ai.core!, testSpan!, noOpFunction); + const undefinedResult = useSpan(this._ai.core!, testSpan!, undefinedFunction); + + // Assert + Assert.equal(emptyResult, undefined, "Empty function should return undefined"); + Assert.equal(noOpResult, undefined, "No-op function should return undefined"); + Assert.equal(undefinedResult, undefined, "Undefined function should return undefined"); + + // Verify span is still valid + Assert.ok(testSpan!.isRecording(), "Span should still be recording after no-op functions"); + } + }); + + this.testCase({ + name: "UseSpan: should use ISpanScope as 'this' when no thisArg provided", + test: () => { + // Arrange + const span = this._ai.startSpan("usespan-test", { + attributes: { "test.id": "useSpan-this-test" } + }); + + let capturedThis: any = null; + let capturedScopeParam: any = null; + + // Act - call useSpan without thisArg (function receives scope as parameter) + const result = useSpan(this._ai.core, span!, function(this: ISpanScope, scope: ISpanScope, arg1: string) { + capturedThis = this; + capturedScopeParam = scope; + + // Verify 'this' is ISpanScope + Assert.ok(this.host, "'this.core' should exist"); + Assert.ok(this.span, "'this.span' should exist"); + + // Verify scope parameter is also ISpanScope + Assert.ok(scope.host, "scope.host should exist"); + Assert.ok(scope.span, "scope.span should exist"); + + return `${arg1}-${scope.span.name}`; + }, undefined, "result"); + + // Assert + Assert.equal(result, "result-usespan-test", "Function should execute and return result"); + Assert.ok(capturedThis, "'this' should be defined"); + Assert.ok(capturedThis.host, "'this.host' should exist"); + Assert.ok(capturedThis.span, "'this.span' should exist"); + Assert.equal(capturedThis.host, this._ai.core, "'this.host' should be the AI core"); + Assert.equal(capturedThis.span, span, "'this.span' should be the passed span"); + + Assert.ok(capturedScopeParam, "scope parameter should be defined"); + Assert.equal(capturedScopeParam.host, this._ai.core, "scope.host should be the AI core"); + Assert.equal(capturedScopeParam.span, span, "scope.span should be the passed span"); + + // Both 'this' and scope param should be the same ISpanScope instance + Assert.equal(capturedThis, capturedScopeParam, "'this' and scope param should be the same ISpanScope instance"); + + span!.end(); + } + }); + + this.testCase({ + name: "UseSpan: should use provided thisArg when specified", + test: () => { + // Arrange + const span = this._ai.startSpan("usespan-thisarg-test"); + + class ServiceClass { + public serviceId: string = "service-456"; + public getData(prefix: string): string { + return `${prefix}-${this.serviceId}`; + } + } + + const service = new ServiceClass(); + let capturedThis: any = null; + let capturedScopeParam: any = null; + + // Act - call useSpan with explicit thisArg + const result = useSpan(this._ai.core, span!, function(this: ServiceClass, scope: ISpanScope) { + capturedThis = this; + capturedScopeParam = scope; + + // 'this' should be the service instance + Assert.equal(this.serviceId, "service-456", "'this.serviceId' should match"); + Assert.ok(typeof this.getData === "function", "'this.getData' should be a function"); + + // scope parameter should still be ISpanScope + Assert.ok(scope.host, "scope.host should exist"); + Assert.ok(scope.span, "scope.span should exist"); + + return this.getData("custom"); + }, service); + + // Assert + Assert.equal(result, "custom-service-456", "Function should execute with custom this context"); + Assert.ok(capturedThis, "'this' should be defined"); + Assert.equal(capturedThis, service, "'this' should be the service instance"); + Assert.equal(capturedThis.serviceId, "service-456", "'this.serviceId' should match"); + Assert.ok(!capturedThis.host, "Custom this should not have host property"); + Assert.ok(!capturedThis.span, "Custom this should not have span property"); + + Assert.ok(capturedScopeParam, "scope parameter should be defined"); + Assert.ok(capturedScopeParam.host, "scope.host should exist even with custom this"); + Assert.ok(capturedScopeParam.span, "scope.span should exist even with custom this"); + + span!.end(); + } + }); + + this.testCase({ + name: "UseSpan: scope parameter should provide access to core and span operations", + test: () => { + // Arrange + this._trackCalls = []; + const span = this._ai.startSpan("scope-operations-test"); + + // Act - use scope parameter to perform operations + useSpan(this._ai.core, span!, (scope: ISpanScope) => { + // Use scope.span to set attributes + scope.span.setAttribute("operation.name", "data-processing"); + scope.span.setAttribute("operation.step", 1); + + // Use scope.span to set status + scope.span.setStatus({ + code: 0, + message: "Operation successful" + }); + + // Use scope.span to get context + const spanContext = scope.span.spanContext(); + Assert.ok(spanContext.traceId, "Should access span context via scope"); + Assert.ok(spanContext.spanId, "Should access span ID via scope"); + + // Verify span name + Assert.equal(scope.span.name, "scope-operations-test", "Span name should be accessible"); + }); + + // Assert + Assert.ok(span, "Span should exist"); + Assert.equal(span!.name, "scope-operations-test", "Span name should match"); + + span!.end(); + + Assert.equal(this._trackCalls.length, 1, "Should generate telemetry"); + Assert.ok(this._trackCalls[0].baseData?.properties, "Should have properties"); + Assert.equal(this._trackCalls[0].baseData.properties["operation.name"], "data-processing", "Attributes should be preserved"); + } + }); + + this.testCase({ + name: "UseSpan: 'this' binding with nested useSpan calls", + test: () => { + // Arrange + const span = this._ai.startSpan("nested-calls-test"); + + const outerContext = { + contextName: "outer", + value: 100 + }; + + let outerThisCapture: any = null; + let innerThisCapture: any = null; + let ai = this._ai; + + // Act - nested useSpan calls with different thisArg + useSpan(this._ai.core, span!, function(this: typeof outerContext, outerScope: ISpanScope) { + outerThisCapture = this; + Assert.equal(this.contextName, "outer", "Outer 'this' should be outer context"); + Assert.equal(this.value, 100, "Outer 'this.value' should match"); + + const innerSpan = ai.startSpan("inner-nested-span"); + + useSpan(ai.core, innerSpan!, function(this: ISpanScope, innerScope: ISpanScope) { + innerThisCapture = this; + // Inner call without explicit thisArg - should be ISpanScope + Assert.ok(this.host, "Inner 'this' should be ISpanScope"); + Assert.ok(this.span, "Inner 'this.span' should exist"); + Assert.equal(this.span.name, "inner-nested-span", "Inner span name should match"); + }); + + innerSpan!.end(); + }, outerContext); + + // Assert + Assert.ok(outerThisCapture, "Outer 'this' should be captured"); + Assert.equal(outerThisCapture.contextName, "outer", "Outer context should be preserved"); + + Assert.ok(innerThisCapture, "Inner 'this' should be captured"); + Assert.ok(innerThisCapture.host, "Inner 'this' should have host"); + Assert.ok(innerThisCapture.span, "Inner 'this' should have span"); + + span!.end(); + } + }); + + this.testCase({ + name: "UseSpan: verify scope.restore() is called to restore previous active span", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-span"); + const innerSpan = this._ai.startSpan("inner-span"); + + let activeSpanBeforeUseSpan: any = null; + let activeSpanInsideUseSpan: any = null; + let activeSpanAfterUseSpan: any = null; + + // Act + activeSpanBeforeUseSpan = this._ai.core.getActiveSpan ? this._ai.core.getActiveSpan() : null; + + useSpan(this._ai.core, innerSpan!, (scope: ISpanScope) => { + activeSpanInsideUseSpan = this._ai.core.getActiveSpan ? this._ai.core.getActiveSpan() : null; + Assert.equal(activeSpanInsideUseSpan, innerSpan, "Active span inside useSpan should be inner span"); + }); + + activeSpanAfterUseSpan = this._ai.core.getActiveSpan ? this._ai.core.getActiveSpan() : null; + + // Assert + // Active span should be restored after useSpan completes + Assert.equal(activeSpanAfterUseSpan, activeSpanBeforeUseSpan, + "Active span should be restored after useSpan completes"); + + innerSpan!.end(); + outerSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: trace context should match active span context inside useSpan", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("trace-context-match-test", { + attributes: { + "test.type": "trace-context-validation" + } + }); + Assert.ok(testSpan, "Test span should be created"); + + let traceCtxInsideUseSpan: any = null; + let spanContextInsideUseSpan: any = null; + let activeSpanInsideUseSpan: any = null; + + // Act + useSpan(this._ai.core, testSpan!, (scope: ISpanScope) => { + // Get trace context from core + traceCtxInsideUseSpan = this._ai.core.getTraceCtx(false); + + // Get span context from the span + spanContextInsideUseSpan = scope.span.spanContext(); + + // Get active span + activeSpanInsideUseSpan = this._ai.core.getActiveSpan ? this._ai.core.getActiveSpan() : null; + }); + + // Assert + Assert.ok(traceCtxInsideUseSpan, "Trace context should exist inside useSpan"); + Assert.ok(spanContextInsideUseSpan, "Span context should exist"); + Assert.ok(activeSpanInsideUseSpan, "Active span should be set"); + + // Verify active span matches the useSpan span + Assert.equal(activeSpanInsideUseSpan, testSpan, "Active span should be the useSpan span"); + + // Verify trace context matches span context + Assert.equal(traceCtxInsideUseSpan.traceId, spanContextInsideUseSpan.traceId, + "Trace context traceId should match span context traceId"); + Assert.equal(traceCtxInsideUseSpan.spanId, spanContextInsideUseSpan.spanId, + "Trace context spanId should match span context spanId"); + Assert.equal(traceCtxInsideUseSpan.traceFlags, spanContextInsideUseSpan.traceFlags, + "Trace context traceFlags should match span context traceFlags"); + + testSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: trace context updates when switching between nested useSpan calls", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-trace-span"); + const innerSpan = this._ai.startSpan("inner-trace-span"); + + let outerTraceCtx: any = null; + let outerSpanCtx: any = null; + let innerTraceCtx: any = null; + let innerSpanCtx: any = null; + + // Act + useSpan(this._ai.core, outerSpan!, (outerScope: ISpanScope) => { + outerTraceCtx = this._ai.core.getTraceCtx(false); + outerSpanCtx = outerScope.span.spanContext(); + + // Verify outer trace context matches outer span + Assert.equal(outerTraceCtx.spanId, outerSpanCtx.spanId, + "Outer trace context should match outer span"); + + // Nested useSpan with different span + useSpan(this._ai.core, innerSpan!, (innerScope: ISpanScope) => { + innerTraceCtx = this._ai.core.getTraceCtx(false); + innerSpanCtx = innerScope.span.spanContext(); + + // Verify inner trace context matches inner span + Assert.equal(innerTraceCtx.spanId, innerSpanCtx.spanId, + "Inner trace context should match inner span"); + + // Verify inner context is different from outer + Assert.notEqual(innerTraceCtx.spanId, outerTraceCtx.spanId, + "Inner and outer trace contexts should have different spanIds"); + }); + + // After inner useSpan, verify we're back to outer context + const restoredTraceCtx = this._ai.core.getTraceCtx(false); + Assert.equal(restoredTraceCtx.spanId, outerSpanCtx.spanId, + "Trace context should be restored to outer span after inner useSpan completes"); + }); + + outerSpan!.end(); + innerSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: child spans created inside useSpan inherit correct parent context", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-for-child-test"); + + let childSpanContext: any = null; + let parentSpanContext: any = null; + + // Act + useSpan(this._ai.core, parentSpan!, (scope: ISpanScope) => { + parentSpanContext = scope.span.spanContext(); + + // Create a child span while parent is active + const childSpan = this._ai.startSpan("child-span-in-useSpan"); + childSpanContext = childSpan!.spanContext(); + + // Verify trace context matches parent + const traceCtx = this._ai.core.getTraceCtx(false); + Assert.equal(traceCtx.spanId, parentSpanContext.spanId, + "Trace context should match parent span inside useSpan"); + + childSpan!.end(); + }); + + // Assert + Assert.ok(childSpanContext, "Child span context should exist"); + Assert.ok(parentSpanContext, "Parent span context should exist"); + + // Child should have same traceId as parent but different spanId + Assert.equal(childSpanContext.traceId, parentSpanContext.traceId, + "Child span should have same traceId as parent"); + Assert.notEqual(childSpanContext.spanId, parentSpanContext.spanId, + "Child span should have different spanId from parent"); + + parentSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: trace context is restored after useSpan completes", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("temporary-trace-span"); + + let traceCtxBefore: any = null; + let traceCtxInside: any = null; + let traceCtxAfter: any = null; + + // Act + traceCtxBefore = this._ai.core.getTraceCtx(false); + + useSpan(this._ai.core, testSpan!, () => { + traceCtxInside = this._ai.core.getTraceCtx(false); + }); + + traceCtxAfter = this._ai.core.getTraceCtx(false); + + // Assert + Assert.ok(traceCtxBefore, "Trace context should exist before useSpan (created by startSpan)"); + Assert.ok(traceCtxInside, "Trace context should exist inside useSpan"); + Assert.equal(traceCtxInside.spanId, testSpan!.spanContext().spanId, + "Trace context inside useSpan should match the test span"); + Assert.ok(traceCtxAfter, "Trace context should exist after useSpan"); + Assert.equal(traceCtxAfter.spanId, traceCtxBefore.spanId, + "Trace context should be restored to previous state after useSpan"); + + testSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: trace context reflects parent span when useSpan is nested in another active span", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-active-span"); + const provider = this._ai.core.getTraceProvider(); + + this._ai.setActiveSpan(outerSpan!); + + const innerSpan = this._ai.startSpan("inner-usespan-span"); + + let outerSpanCtx: any = null; + let traceCtxBeforeUseSpan: any = null; + let traceCtxInsideUseSpan: any = null; + let traceCtxAfterUseSpan: any = null; + + // Act + outerSpanCtx = outerSpan!.spanContext(); + traceCtxBeforeUseSpan = this._ai.core.getTraceCtx(false); + + // Verify initial trace context matches outer span + Assert.equal(traceCtxBeforeUseSpan.spanId, outerSpanCtx.spanId, + "Trace context should initially match outer span"); + + useSpan(this._ai.core, innerSpan!, (scope: ISpanScope) => { + traceCtxInsideUseSpan = this._ai.core.getTraceCtx(false); + const innerSpanCtx = scope.span.spanContext(); + + // Inside useSpan, trace context should match inner span + Assert.equal(traceCtxInsideUseSpan.spanId, innerSpanCtx.spanId, + "Trace context inside useSpan should match inner span"); + }); + + traceCtxAfterUseSpan = this._ai.core.getTraceCtx(false); + + // After useSpan, trace context should be restored to outer span + Assert.equal(traceCtxAfterUseSpan.spanId, outerSpanCtx.spanId, + "Trace context should be restored to outer span after useSpan"); + + innerSpan!.end(); + outerSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: trace context traceState is accessible inside useSpan", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("tracestate-test-span"); + + let traceStateInside: any = null; + + // Act + useSpan(this._ai.core, testSpan!, () => { + const traceCtx = this._ai.core.getTraceCtx(false); + traceStateInside = traceCtx ? traceCtx.traceState : null; + }); + + // Assert + Assert.ok(traceStateInside !== undefined, + "Trace state should be accessible inside useSpan"); + + testSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: span created inside useSpan has parent context matching outer trace context", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-parent-span"); + + this._ai.setActiveSpan(outerSpan!); + + let outerTraceCtx: any = null; + let innerSpanParentCtx: any = null; + let innerSpanCreated: any = null; + + // Act + outerTraceCtx = this._ai.core.getTraceCtx(false); + + useSpan(this._ai.core, outerSpan!, (scope: ISpanScope) => { + // Create a new span inside useSpan + innerSpanCreated = this._ai.startSpan("inner-child-span"); + + // Get the parent context of the newly created span + if (innerSpanCreated) { + innerSpanParentCtx = innerSpanCreated.parentSpanContext; + } + + innerSpanCreated!.end(); + }); + + // Assert + Assert.ok(outerTraceCtx, "Outer trace context should exist"); + Assert.ok(innerSpanParentCtx, "Inner span should have parent context"); + + // Verify parent context matches outer trace context + Assert.equal(innerSpanParentCtx.traceId, outerTraceCtx.traceId, + "Inner span parent traceId should match outer trace context traceId"); + Assert.equal(innerSpanParentCtx.spanId, outerTraceCtx.spanId, + "Inner span parent spanId should match outer trace context spanId"); + + outerSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: span parent context matches trace context when useSpan wraps different span", + test: () => { + // Arrange - Create initial trace context + const contextSpan = this._ai.startSpan("context-span"); + + this._ai.setActiveSpan(contextSpan!); + + const contextTraceCtx = this._ai.core.getTraceCtx(false); + + // Create a different span to use in useSpan + const wrapperSpan = this._ai.startSpan("wrapper-span"); + + let spanCreatedInCallback: any = null; + let spanParentCtx: any = null; + + // Act - useSpan with a different span than what's in trace context + useSpan(this._ai.core, wrapperSpan!, (scope: ISpanScope) => { + // The active span is now wrapperSpan + // Create a child span - it should have wrapperSpan as parent + spanCreatedInCallback = this._ai.startSpan("child-of-wrapper"); + + if (spanCreatedInCallback) { + spanParentCtx = spanCreatedInCallback.parentSpanContext; + } + + spanCreatedInCallback!.end(); + }); + + // Assert + Assert.ok(spanParentCtx, "Child span should have parent context"); + + // Parent should be wrapperSpan (the useSpan span), not contextSpan + const wrapperSpanCtx = wrapperSpan!.spanContext(); + Assert.equal(spanParentCtx.spanId, wrapperSpanCtx.spanId, + "Child span parent should be the wrapper span from useSpan"); + Assert.notEqual(spanParentCtx.spanId, contextTraceCtx.spanId, + "Child span parent should NOT be the original context span"); + + wrapperSpan!.end(); + contextSpan!.end(); + } + }); + + this.testCase({ + name: "UseSpan: multiple nested spans maintain correct parent-child relationships with trace context", + test: () => { + // Arrange + const rootSpan = this._ai.startSpan("root-span"); + + this._ai.setActiveSpan(rootSpan!); + + const rootTraceCtx = this._ai.core.getTraceCtx(false); + const level1Span = this._ai.startSpan("level1-span"); + + let level2SpanParent: any = null; + let level2SpanCtx: any = null; + let level3SpanParent: any = null; + + // Act - Nested useSpan calls + useSpan(this._ai.core, level1Span!, (scope1: ISpanScope) => { + const level1TraceCtx = this._ai.core.getTraceCtx(false); + + // Create level2 span - should have level1 as parent + const level2Span = this._ai.startSpan("level2-span"); + if (level2Span) { + level2SpanParent = level2Span.parentSpanContext; + level2SpanCtx = level2Span.spanContext(); + } + + useSpan(this._ai.core, level2Span!, (scope2: ISpanScope) => { + const level2TraceCtx = this._ai.core.getTraceCtx(false); + + // Create level3 span - should have level2 as parent + const level3Span = this._ai.startSpan("level3-span"); + if (level3Span) { + level3SpanParent = level3Span.parentSpanContext; + } + + // Verify level3 parent matches level2 trace context + Assert.equal(level3SpanParent.spanId, level2TraceCtx.spanId, + "Level3 span parent should match level2 trace context"); + + level3Span!.end(); + }); + + // Verify level2 parent matches level1 trace context + Assert.equal(level2SpanParent.spanId, level1TraceCtx.spanId, + "Level2 span parent should match level1 trace context"); + + level2Span!.end(); + }); + + // Assert + Assert.ok(level2SpanParent, "Level2 span should have parent context"); + Assert.ok(level2SpanCtx, "Level2 span context should exist"); + Assert.ok(level3SpanParent, "Level3 span should have parent context"); + + // Verify the chain: root -> level1 -> level2 -> level3 + Assert.equal(level2SpanParent.spanId, level1Span!.spanContext().spanId, + "Level2 parent should be level1"); + Assert.equal(level3SpanParent.spanId, level2SpanCtx.spanId, + "Level3 parent should be level2"); + + level1Span!.end(); + rootSpan!.end(); + } + }); + } +} \ No newline at end of file diff --git a/AISKU/Tests/Unit/src/WithSpan.Tests.ts b/AISKU/Tests/Unit/src/WithSpan.Tests.ts new file mode 100644 index 000000000..534520037 --- /dev/null +++ b/AISKU/Tests/Unit/src/WithSpan.Tests.ts @@ -0,0 +1,1114 @@ +import { AITestClass, Assert } from '@microsoft/ai-test-framework'; +import { ApplicationInsights } from '../../../src/applicationinsights-web'; +import { + IReadableSpan, eOTelSpanKind, eOTelSpanStatusCode, withSpan, ITelemetryItem, ISpanScope, ITraceHost +} from "@microsoft/applicationinsights-core-js"; +export class WithSpanTests extends AITestClass { + private static readonly _instrumentationKey = 'b7170927-2d1c-44f1-acec-59f4e1751c11'; + private static readonly _connectionString = `InstrumentationKey=${WithSpanTests._instrumentationKey}`; + + private _ai!: ApplicationInsights; + + // Track calls to track for validation + private _trackCalls: ITelemetryItem[] = []; + + constructor(testName?: string) { + super(testName || "WithSpanTests"); + } + + public testInitialize() { + try { + this.useFakeServer = false; + this._trackCalls = []; + + this._ai = new ApplicationInsights({ + config: { + connectionString: WithSpanTests._connectionString, + disableAjaxTracking: false, + disableXhr: false, + maxBatchInterval: 0, + disableExceptionTracking: false + } + }); + + // Initialize the SDK + this._ai.loadAppInsights(); + + // Hook core.track to capture calls + const originalTrack = this._ai.core.track; + this._ai.core.track = (item: ITelemetryItem) => { + this._trackCalls.push(item); + return originalTrack.call(this._ai.core, item); + }; + + } catch (e) { + console.error('Failed to initialize WithSpan tests: ' + e); + throw e; + } + } + + public testFinishedCleanup() { + if (this._ai && this._ai.unload) { + this._ai.unload(false); + } + } + + public registerTests() { + this.addTests(); + } + + private addTests(): void { + + this.testCase({ + name: "WithSpan: withSpan should be available as exported function", + test: () => { + // Verify that withSpan is available as an import + Assert.ok(typeof withSpan === 'function', "withSpan should be available as exported function"); + } + }); + + this.testCase({ + name: "WithSpan: should execute function within span context", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-context-test", { + kind: eOTelSpanKind.SERVER, + attributes: { + "test.type": "context-execution" + } + }); + + Assert.ok(testSpan, "Test span should be created"); + Assert.ok(this._ai.core, "Core should be available"); + + let capturedActiveSpan: IReadableSpan | null = null; + const testFunction = () => { + capturedActiveSpan = this._ai.core!.getActiveSpan(); + return "context-success"; + }; + + // Act + const result = withSpan(this._ai.core!, testSpan!, testFunction); + + // Assert + Assert.equal(result, "context-success", "Function should execute and return result"); + Assert.ok(capturedActiveSpan, "Function should have access to active span"); + Assert.equal(capturedActiveSpan, testSpan, "Active span should be the provided test span"); + } + }); + + this.testCase({ + name: "WithSpan: should work with telemetry tracking inside span context", + test: () => { + // Arrange + this._trackCalls = []; + const testSpan = this._ai.startSpan("withSpan-telemetry-test", { + attributes: { + "operation.name": "telemetry-tracking" + } + }); + + Assert.ok(testSpan, "Test span should be created"); + + const telemetryFunction = () => { + // Track some telemetry within the span context + this._ai.trackEvent({ + name: "operation-event", + properties: { + "event.source": "withSpan-context" + } + }); + + this._ai.trackMetric({ + name: "operation.duration", + average: 123.45 + }); + + return "telemetry-tracked"; + }; + + // Act + const result = withSpan(this._ai.core!, testSpan!, telemetryFunction); + + // Assert + Assert.equal(result, "telemetry-tracked", "Function should complete successfully"); + + // End the span to trigger trace generation + testSpan!.end(); + + // Verify track was called for the span + Assert.equal(this._trackCalls.length, 3, "Should have one track call from span ending"); + const item = this._trackCalls[2]; + Assert.ok(item.baseData && item.baseData.properties, "Item should have properties"); + Assert.equal("withSpan-telemetry-test", item.baseData.name, "Should include span name in properties"); + } + }); + + this.testCase({ + name: "WithSpan: should handle complex function arguments and return values", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-arguments-test"); + Assert.ok(testSpan, "Test span should be created"); + + const complexFunction = ( + stringArg: string, + numberArg: number, + objectArg: { key: string; value: number }, + arrayArg: string[] + ) => { + return { + processedString: stringArg.toUpperCase(), + doubledNumber: numberArg * 2, + extractedValue: objectArg.value, + joinedArray: arrayArg.join('-'), + timestamp: Date.now() + }; + }; + + const inputObject = { key: "test-key", value: 42 }; + const inputArray = ["item1", "item2", "item3"]; + + // Act + const result = withSpan( + this._ai.core!, + testSpan!, + complexFunction, + undefined, + "hello world", + 10, + inputObject, + inputArray + ); + + // Assert + Assert.equal(result.processedString, "HELLO WORLD", "String should be processed correctly"); + Assert.equal(result.doubledNumber, 20, "Number should be doubled correctly"); + Assert.equal(result.extractedValue, 42, "Object value should be extracted correctly"); + Assert.equal(result.joinedArray, "item1-item2-item3", "Array should be joined correctly"); + Assert.ok(result.timestamp > 0, "Timestamp should be generated"); + } + }); + + this.testCase({ + name: "WithSpan: should handle function with this context binding", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-this-binding-test"); + Assert.ok(testSpan, "Test span should be created"); + + class TestService { + private _serviceId: string; + private _multiplier: number; + + constructor(id: string, multiplier: number) { + this._serviceId = id; + this._multiplier = multiplier; + } + + public processValue(input: number): { serviceId: string; result: number; multiplied: number } { + return { + serviceId: this._serviceId, + result: input + 100, + multiplied: input * this._multiplier + }; + } + } + + const service = new TestService("test-service-123", 3); + + // Act + const result = withSpan( + this._ai.core!, + testSpan!, + service.processValue, + service, + 25 + ); + + // Assert + Assert.equal(result.serviceId, "test-service-123", "Service ID should be preserved via this binding"); + Assert.equal(result.result, 125, "Input should be processed correctly"); + Assert.equal(result.multiplied, 75, "Multiplication should use instance property"); + } + }); + + this.testCase({ + name: "WithSpan: should maintain span context across async-like operations", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-async-like-test", { + attributes: { + "operation.type": "async-simulation" + } + }); + Assert.ok(testSpan, "Test span should be created"); + + let spanDuringCallback: IReadableSpan | null = null; + let callbackExecuted = false; + + const asyncLikeFunction = (callback: (data: string) => void) => { + // Simulate async work that completes synchronously in test + const currentSpan = this._ai.core!.getActiveSpan(); + + // Simulate callback execution (would normally be async) + setTimeout(() => { + spanDuringCallback = this._ai.core!.getActiveSpan(); + callback("async-data"); + callbackExecuted = true; + }, 0); + + return currentSpan ? (currentSpan as IReadableSpan).name : "no-span"; + }; + + // Act + let callbackData = ""; + const callback = (data: string) => { + callbackData = data; + }; + + const result = withSpan(this._ai.core!, testSpan!, asyncLikeFunction, undefined, callback); + + // Assert + Assert.equal(result, "withSpan-async-like-test", "Function should have access to span name"); + + // Note: In a real async scenario, the span context wouldn't automatically + // propagate to the setTimeout callback without additional context management + // This test validates the synchronous behavior of withSpan + } + }); + + this.testCase({ + name: "WithSpan: should handle exceptions and preserve span operations", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-exception-test", { + attributes: { + "test.expects": "exception" + } + }); + Assert.ok(testSpan, "Test span should be created"); + + const exceptionFunction = () => { + // Perform some span operations before throwing + const activeSpan = this._ai.core!.getActiveSpan(); + Assert.ok(activeSpan, "Should have active span before exception"); + + activeSpan!.setAttribute("operation.status", "error"); + activeSpan!.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Operation failed with test exception" + }); + + throw new Error("Test exception for withSpan handling"); + }; + + // Act & Assert + let caughtException: Error | null = null; + try { + withSpan(this._ai.core!, testSpan!, exceptionFunction); + } catch (error) { + caughtException = error as Error; + } + + Assert.ok(caughtException, "Exception should be thrown and caught"); + Assert.equal(caughtException!.message, "Test exception for withSpan handling", "Exception message should be preserved"); + + // Verify span is still valid and operations were applied + Assert.ok(testSpan!.isRecording(), "Span should still be recording after exception"); + const readableSpan = testSpan! as IReadableSpan; + Assert.ok(!readableSpan.ended, "Span should not be ended by withSpan after exception"); + } + }); + + this.testCase({ + name: "WithSpan: should work with nested span operations and child spans", + test: () => { + // Arrange + this._trackCalls = []; + const parentSpan = this._ai.startSpan("parent-operation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "operation.name": "parent-process" + } + }); + Assert.ok(parentSpan, "Parent span should be created"); + + const nestedOperations = () => { + // Verify we have the parent span as active + const currentActive = this._ai.core!.getActiveSpan(); + Assert.equal(currentActive, parentSpan, "Parent span should be active"); + + // Create child operations within the parent span context + const childSpan1 = this._ai.startSpan("child-operation-1", { + attributes: { "child.order": 1 } + }); + childSpan1!.setAttribute("child.status", "completed"); + childSpan1!.end(); + + const childSpan2 = this._ai.startSpan("child-operation-2", { + attributes: { "child.order": 2 } + }); + childSpan2!.setAttribute("child.status", "completed"); + childSpan2!.end(); + + return "nested-operations-completed"; + }; + + // Act + const result = withSpan(this._ai.core!, parentSpan!, nestedOperations); + + // Assert + Assert.equal(result, "nested-operations-completed", "Nested operations should complete successfully"); + + // End parent span to generate telemetry + parentSpan!.end(); + + // Should have 3 telemetry items: parent + 2 children + Assert.equal(this._trackCalls.length, 3, "Should have telemetry for parent and child spans"); + + // Verify span names in properties + const spanNames = this._trackCalls.map(item => item.baseData?.name).filter(n => n); + Assert.ok(spanNames.some(name => name === "parent-operation"), "Should have parent span telemetry"); + Assert.ok(spanNames.some(name => name === "child-operation-1"), "Should have child-1 span telemetry"); + Assert.ok(spanNames.some(name => name === "child-operation-2"), "Should have child-2 span telemetry"); + } + }); + + this.testCase({ + name: "WithSpan: should support different return value types", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-return-types-test"); + Assert.ok(testSpan, "Test span should be created"); + + // Test various return types + const stringResult = withSpan(this._ai.core!, testSpan!, () => "string-result"); + const numberResult = withSpan(this._ai.core!, testSpan!, () => 42.5); + const booleanResult = withSpan(this._ai.core!, testSpan!, () => true); + const arrayResult = withSpan(this._ai.core!, testSpan!, () => [1, 2, 3]); + const objectResult = withSpan(this._ai.core!, testSpan!, () => ({ key: "value", nested: { prop: 123 } })); + const nullResult = withSpan(this._ai.core!, testSpan!, () => null); + const undefinedResult = withSpan(this._ai.core!, testSpan!, () => undefined); + + // Assert + Assert.equal(stringResult, "string-result", "String return should work"); + Assert.equal(numberResult, 42.5, "Number return should work"); + Assert.equal(booleanResult, true, "Boolean return should work"); + Assert.equal(arrayResult.length, 3, "Array return should work"); + Assert.equal(arrayResult[1], 2, "Array elements should be preserved"); + Assert.equal(objectResult.key, "value", "Object properties should be preserved"); + Assert.equal(objectResult.nested.prop, 123, "Nested object properties should be preserved"); + Assert.equal(nullResult, null, "Null return should work"); + Assert.equal(undefinedResult, undefined, "Undefined return should work"); + } + }); + + this.testCase({ + name: "WithSpan: should handle rapid successive calls efficiently", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-performance-test"); + Assert.ok(testSpan, "Test span should be created"); + + const iterations = 100; + let totalResult = 0; + + // Simple computation function + const computeFunction = (input: number) => { + return input * 2 + 1; + }; + + const startTime = Date.now(); + + // Act - Multiple rapid withSpan calls + for (let i = 0; i < iterations; i++) { + const result = withSpan(this._ai.core!, testSpan!, computeFunction, undefined, i); + totalResult += result; + } + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Assert + const expectedTotal = Array.from({length: iterations}, (_, i) => i * 2 + 1).reduce((sum, val) => sum + val, 0); + Assert.equal(totalResult, expectedTotal, "All computations should be correct"); + + // Performance assertion - should complete reasonably quickly + Assert.ok(duration < 1000, `Performance test should complete quickly: ${duration}ms for ${iterations} iterations`); + + // Verify span is still valid after many operations + Assert.ok(testSpan!.isRecording(), "Span should still be recording after multiple withSpan calls"); + } + }); + + this.testCase({ + name: "WithSpan: should integrate with AI telemetry correlation", + test: () => { + // Arrange + this._trackCalls = []; + const operationSpan = this._ai.startSpan("user-operation", { + kind: eOTelSpanKind.SERVER, + attributes: { + "user.id": "user-123", + "operation.type": "data-processing" + } + }); + Assert.ok(operationSpan, "Operation span should be created"); + + const businessLogicFunction = (userId: string, dataType: string) => { + // Track multiple telemetry items within span context + this._ai.trackEvent({ + name: "data-processing-started", + properties: { + "user.id": userId, + "data.type": dataType, + "processing.stage": "initialization" + } + }); + + // Simulate some processing steps + for (let step = 1; step <= 3; step++) { + this._ai.trackMetric({ + name: "processing.step.duration", + average: step * 10.5, + properties: { + "step.number": step.toString() + } + }); + } + + this._ai.trackEvent({ + name: "data-processing-completed", + properties: { + "user.id": userId, + "data.type": dataType, + "processing.stage": "completion", + "steps.completed": "3" + } + }); + + return { + userId: userId, + dataType: dataType, + stepsCompleted: 3, + status: "success" + }; + }; + + // Act + const result = withSpan( + this._ai.core!, + operationSpan!, + businessLogicFunction, + undefined, + "user-123", + "customer-data" + ); + + // End the span to generate trace + operationSpan!.end(); + + // Assert + Assert.equal(result.userId, "user-123", "User ID should be processed correctly"); + Assert.equal(result.dataType, "customer-data", "Data type should be processed correctly"); + Assert.equal(result.stepsCompleted, 3, "All processing steps should be completed"); + Assert.equal(result.status, "success", "Operation should complete successfully"); + + // Verify span telemetry was generated + Assert.equal(this._trackCalls.length, 6, "Should have one track call from span ending"); + const spanItem = this._trackCalls[5]; + Assert.ok(spanItem.baseData && spanItem.baseData.properties, "Item should have properties"); + Assert.equal("user-operation", spanItem.baseData.name, "Should include span name"); + + // Verify span attributes are included in properties + Assert.equal(spanItem.baseData.properties["user.id"], "user-123", "Span attributes should be included in telemetry"); + Assert.equal(spanItem.baseData.properties["operation.type"], "data-processing", "All span attributes should be preserved"); + } + }); + + this.testCase({ + name: "WithSpan: should handle empty or no-op functions gracefully", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("withSpan-noop-test"); + Assert.ok(testSpan, "Test span should be created"); + + // Test empty function + const emptyFunction = () => {}; + + // Test function that just returns without doing anything + const noOpFunction = () => { + return; + }; + + // Test function that returns undefined explicitly + const undefinedFunction = () => { + return undefined; + }; + + // Act + const emptyResult = withSpan(this._ai.core!, testSpan!, emptyFunction); + const noOpResult = withSpan(this._ai.core!, testSpan!, noOpFunction); + const undefinedResult = withSpan(this._ai.core!, testSpan!, undefinedFunction); + + // Assert + Assert.equal(emptyResult, undefined, "Empty function should return undefined"); + Assert.equal(noOpResult, undefined, "No-op function should return undefined"); + Assert.equal(undefinedResult, undefined, "Undefined function should return undefined"); + + // Verify span is still valid + Assert.ok(testSpan!.isRecording(), "Span should still be recording after no-op functions"); + } + }); + + this.testCase({ + name: "WithSpan: should use ISpanScope as 'this' when no thisArg provided", + test: () => { + // Arrange + const span = this._ai.startSpan("test-span", { + attributes: { "test.id": "withSpan-this-test" } + }); + + let capturedThis: any = null; + let capturedHost: ITraceHost | null = null; + let capturedSpan: any = null; + + // Act - call withSpan without thisArg + const result = withSpan(this._ai.core, span!, function(this: ISpanScope, arg1: string, arg2: number) { + capturedThis = this; + capturedHost = this.host; + capturedSpan = this.span; + return `${arg1}-${arg2}`; + }, undefined, "test", 42); + + // Assert + Assert.equal(result, "test-42", "Function should execute and return result"); + Assert.ok(capturedThis, "'this' should be defined"); + Assert.ok(capturedThis.host, "'this.host' should exist"); + Assert.ok(capturedThis.span, "'this.span' should exist"); + Assert.equal(capturedHost, this._ai.core, "'this.host' should be the AI core"); + Assert.equal(capturedSpan, span, "'this.span' should be the passed span"); + Assert.equal(capturedThis.span.name, "test-span", "'this.span.name' should match"); + + span!.end(); + } + }); + + this.testCase({ + name: "WithSpan: should use provided thisArg when specified", + test: () => { + // Arrange + const span = this._ai.startSpan("test-span-thisarg"); + + const customContext = { + contextId: "custom-123", + multiplier: 10 + }; + + let capturedThis: any = null; + + // Act - call withSpan with explicit thisArg + const result = withSpan(this._ai.core, span!, function(this: typeof customContext, arg1: number) { + capturedThis = this; + return arg1 * this.multiplier; + }, customContext, 5); + + // Assert + Assert.equal(result, 50, "Function should execute with custom this context"); + Assert.ok(capturedThis, "'this' should be defined"); + Assert.equal(capturedThis, customContext, "'this' should be the custom context"); + Assert.equal(capturedThis.contextId, "custom-123", "'this.contextId' should match"); + Assert.equal(capturedThis.multiplier, 10, "'this.multiplier' should match"); + Assert.ok(!capturedThis.core, "Custom this should not have core property"); + Assert.ok(!capturedThis.span, "Custom this should not have span property"); + + span!.end(); + } + }); + + this.testCase({ + name: "WithSpan: arrow functions should not override 'this' binding", + test: () => { + // Arrange + const span = this._ai.startSpan("arrow-function-test"); + + // Act - arrow functions capture their lexical 'this' + const result = withSpan(this._ai.core, span!, (arg: string) => { + // Arrow function - 'this' is lexically bound to the test class instance + Assert.ok(this._ai, "Arrow function should have access to test class 'this'"); + return `arrow-${arg}`; + }, undefined, "result"); + + // Assert + Assert.equal(result, "arrow-result", "Arrow function should execute correctly"); + Assert.ok(this._ai, "Test class instance should still be accessible"); + + span!.end(); + } + }); + + this.testCase({ + name: "WithSpan: verify ISpanScope.restore() is called to restore previous active span", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-span"); + const innerSpan = this._ai.startSpan("inner-span"); + + let activeSpanBeforeWithSpan: any = null; + let activeSpanInsideWithSpan: any = null; + let activeSpanAfterWithSpan: any = null; + + // Act + activeSpanBeforeWithSpan = this._ai.core.getActiveSpan ? this._ai.core.getActiveSpan() : null; + + withSpan(this._ai.core, innerSpan!, () => { + activeSpanInsideWithSpan = this._ai.core.getActiveSpan ? this._ai.core.getActiveSpan() : null; + Assert.equal(activeSpanInsideWithSpan, innerSpan, "Active span inside withSpan should be inner span"); + }); + + activeSpanAfterWithSpan = this._ai.core.getActiveSpan ? this._ai.core.getActiveSpan() : null; + + // Assert + // Active span should be restored after withSpan completes + Assert.equal(activeSpanAfterWithSpan, activeSpanBeforeWithSpan, + "Active span should be restored after withSpan completes"); + + innerSpan!.end(); + outerSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: 'this' binding with nested withSpan calls", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-withspan"); + const innerSpan = this._ai.startSpan("inner-withspan"); + + const outerContext = { + contextName: "outer", + value: 100 + }; + + let outerThisCapture: any = null; + let innerThisCapture: any = null; + let ai = this._ai; + + // Act - nested withSpan calls with different thisArg + withSpan(ai.core, outerSpan!, function(this: typeof outerContext, arg: number) { + outerThisCapture = this; + Assert.equal(this.contextName, "outer", "Outer 'this' should be outer context"); + Assert.equal(this.value, 100, "Outer 'this.value' should match"); + + withSpan(ai.core, innerSpan!, function(this: ISpanScope) { + innerThisCapture = this; + // Inner call without explicit thisArg - should be ISpanScope + Assert.ok(this.host, "Inner 'this' should be ISpanScope"); + Assert.ok(this.span, "Inner 'this.span' should exist"); + Assert.equal(this.span.name, "inner-withspan", "Inner span name should match"); + }); + + return arg * this.value; + }, outerContext, 2); + + // Assert + Assert.ok(outerThisCapture, "Outer 'this' should be captured"); + Assert.equal(outerThisCapture.contextName, "outer", "Outer context should be preserved"); + + Assert.ok(innerThisCapture, "Inner 'this' should be captured"); + Assert.ok(innerThisCapture.host, "Inner 'this' should have host"); + Assert.ok(innerThisCapture.span, "Inner 'this' should have span"); + + innerSpan!.end(); + outerSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: trace context should match active span context inside withSpan", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("trace-context-match-test", { + attributes: { + "test.type": "trace-context-validation" + } + }); + Assert.ok(testSpan, "Test span should be created"); + + let traceCtxInsideWithSpan: any = null; + let spanContextInsideWithSpan: any = null; + let activeSpanInsideWithSpan: any = null; + + // Act + withSpan(this._ai.core, testSpan!, function(this: ISpanScope) { + // Get trace context from core + traceCtxInsideWithSpan = this.host.getTraceCtx(false); + + // Get span context from the span + spanContextInsideWithSpan = this.span.spanContext(); + + // Get active span + activeSpanInsideWithSpan = this.host.getActiveSpan ? this.host.getActiveSpan() : null; + }); + + // Assert + Assert.ok(traceCtxInsideWithSpan, "Trace context should exist inside withSpan"); + Assert.ok(spanContextInsideWithSpan, "Span context should exist"); + Assert.ok(activeSpanInsideWithSpan, "Active span should be set"); + + // Verify active span matches the withSpan span + Assert.equal(activeSpanInsideWithSpan, testSpan, "Active span should be the withSpan span"); + + // Verify trace context matches span context + Assert.equal(traceCtxInsideWithSpan.traceId, spanContextInsideWithSpan.traceId, + "Trace context traceId should match span context traceId"); + Assert.equal(traceCtxInsideWithSpan.spanId, spanContextInsideWithSpan.spanId, + "Trace context spanId should match span context spanId"); + Assert.equal(traceCtxInsideWithSpan.traceFlags, spanContextInsideWithSpan.traceFlags, + "Trace context traceFlags should match span context traceFlags"); + + testSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: trace context updates when switching between nested withSpan calls", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-trace-span"); + const innerSpan = this._ai.startSpan("inner-trace-span"); + + let outerTraceCtx: any = null; + let outerSpanCtx: any = null; + let innerTraceCtx: any = null; + let innerSpanCtx: any = null; + let ai = this._ai; + + // Act + withSpan(ai.core, outerSpan!, function(this: ISpanScope) { + outerTraceCtx = this.host.getTraceCtx(false); + outerSpanCtx = this.span.spanContext(); + + // Verify outer trace context matches outer span + Assert.equal(outerTraceCtx.spanId, outerSpanCtx.spanId, + "Outer trace context should match outer span"); + + // Nested withSpan with different span + withSpan(ai.core, innerSpan!, function(this: ISpanScope) { + innerTraceCtx = this.host.getTraceCtx(false); + innerSpanCtx = this.span.spanContext(); + + // Verify inner trace context matches inner span + Assert.equal(innerTraceCtx.spanId, innerSpanCtx.spanId, + "Inner trace context should match inner span"); + + // Verify inner context is different from outer + Assert.notEqual(innerTraceCtx.spanId, outerTraceCtx.spanId, + "Inner and outer trace contexts should have different spanIds"); + }); + + // After inner withSpan, verify we're back to outer context + const restoredTraceCtx = this.host.getTraceCtx(false); + Assert.equal(restoredTraceCtx.spanId, outerSpanCtx.spanId, + "Trace context should be restored to outer span after inner withSpan completes"); + }); + + outerSpan!.end(); + innerSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: child spans created inside withSpan inherit correct parent context", + test: () => { + // Arrange + const parentSpan = this._ai.startSpan("parent-for-child-test"); + let ai = this._ai; + + let childSpanContext: any = null; + let parentSpanContext: any = null; + + // Act + withSpan(ai.core, parentSpan!, function(this: ISpanScope) { + parentSpanContext = this.span.spanContext(); + + // Create a child span while parent is active + const childSpan = ai.startSpan("child-span-in-withSpan"); + childSpanContext = childSpan!.spanContext(); + + // Verify trace context matches parent + const traceCtx = this.host.getTraceCtx(false); + Assert.equal(traceCtx.spanId, parentSpanContext.spanId, + "Trace context should match parent span inside withSpan"); + + childSpan!.end(); + }); + + // Assert + Assert.ok(childSpanContext, "Child span context should exist"); + Assert.ok(parentSpanContext, "Parent span context should exist"); + + // Child should have same traceId as parent but different spanId + Assert.equal(childSpanContext.traceId, parentSpanContext.traceId, + "Child span should have same traceId as parent"); + Assert.notEqual(childSpanContext.spanId, parentSpanContext.spanId, + "Child span should have different spanId from parent"); + + parentSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: trace context is restored after withSpan completes", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("temporary-trace-span"); + + let traceCtxBefore: any = null; + let traceCtxInside: any = null; + let traceCtxAfter: any = null; + let ai = this._ai; + + // Act + traceCtxBefore = this._ai.core.getTraceCtx(false); + + withSpan(ai.core, testSpan!, function(this: ISpanScope) { + traceCtxInside = this.host.getTraceCtx(false); + }); + + traceCtxAfter = this._ai.core.getTraceCtx(false); + + // Assert + Assert.ok(traceCtxBefore, "Trace context should exist before withSpan (created by startSpan)"); + Assert.ok(traceCtxInside, "Trace context should exist inside withSpan"); + Assert.equal(traceCtxInside.spanId, testSpan!.spanContext().spanId, + "Trace context inside withSpan should match the test span"); + Assert.ok(traceCtxAfter, "Trace context should exist after withSpan"); + Assert.equal(traceCtxAfter.spanId, traceCtxBefore.spanId, + "Trace context should be restored to previous state after withSpan"); + + testSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: trace context reflects parent span when withSpan is nested in another active span", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-active-span"); + + this._ai.setActiveSpan(outerSpan!); + + const innerSpan = this._ai.startSpan("inner-withspan-span"); + let ai = this._ai; + + let outerSpanCtx: any = null; + let traceCtxBeforeWithSpan: any = null; + let traceCtxInsideWithSpan: any = null; + let traceCtxAfterWithSpan: any = null; + + // Act + outerSpanCtx = outerSpan!.spanContext(); + traceCtxBeforeWithSpan = this._ai.core.getTraceCtx(false); + + // Verify initial trace context matches outer span + Assert.equal(traceCtxBeforeWithSpan.spanId, outerSpanCtx.spanId, + "Trace context should initially match outer span"); + + withSpan(ai.core, innerSpan!, function(this: ISpanScope) { + traceCtxInsideWithSpan = this.host.getTraceCtx(false); + const innerSpanCtx = this.span.spanContext(); + + // Inside withSpan, trace context should match inner span + Assert.equal(traceCtxInsideWithSpan.spanId, innerSpanCtx.spanId, + "Trace context inside withSpan should match inner span"); + }); + + traceCtxAfterWithSpan = this._ai.core.getTraceCtx(false); + + // After withSpan, trace context should be restored to outer span + Assert.equal(traceCtxAfterWithSpan.spanId, outerSpanCtx.spanId, + "Trace context should be restored to outer span after withSpan"); + + innerSpan!.end(); + outerSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: trace context traceState is accessible inside withSpan", + test: () => { + // Arrange + const testSpan = this._ai.startSpan("tracestate-test-span"); + let ai = this._ai; + + let traceStateInside: any = null; + + // Act + withSpan(ai.core, testSpan!, function(this: ISpanScope) { + const traceCtx = this.host.getTraceCtx(false); + traceStateInside = traceCtx ? traceCtx.traceState : null; + }); + + // Assert + Assert.ok(traceStateInside !== undefined, + "Trace state should be accessible inside withSpan"); + + testSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: span created inside withSpan has parent context matching outer trace context", + test: () => { + // Arrange + const outerSpan = this._ai.startSpan("outer-parent-span"); + + this._ai.setActiveSpan(outerSpan!); + + let outerTraceCtx: any = null; + let innerSpanParentCtx: any = null; + let innerSpanCreated: any = null; + let ai = this._ai; + + // Act + outerTraceCtx = this._ai.core.getTraceCtx(false); + + withSpan(ai.core, outerSpan!, function(this: ISpanScope) { + // Create a new span inside withSpan + innerSpanCreated = ai.startSpan("inner-child-span"); + + // Get the parent context of the newly created span + if (innerSpanCreated) { + innerSpanParentCtx = innerSpanCreated.parentSpanContext; + } + + innerSpanCreated!.end(); + }); + + // Assert + Assert.ok(outerTraceCtx, "Outer trace context should exist"); + Assert.ok(innerSpanParentCtx, "Inner span should have parent context"); + + // Verify parent context matches outer trace context + Assert.equal(innerSpanParentCtx.traceId, outerTraceCtx.traceId, + "Inner span parent traceId should match outer trace context traceId"); + Assert.equal(innerSpanParentCtx.spanId, outerTraceCtx.spanId, + "Inner span parent spanId should match outer trace context spanId"); + + outerSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: span parent context matches trace context when withSpan wraps different span", + test: () => { + // Arrange - Create initial trace context + const contextSpan = this._ai.startSpan("context-span"); + + this._ai.setActiveSpan(contextSpan!); + + const contextTraceCtx = this._ai.core.getTraceCtx(false); + + // Create a different span to use in withSpan + const wrapperSpan = this._ai.startSpan("wrapper-span"); + + let spanCreatedInCallback: any = null; + let spanParentCtx: any = null; + let ai = this._ai; + + // Act - withSpan with a different span than what's in trace context + withSpan(ai.core, wrapperSpan!, function(this: ISpanScope) { + // The active span is now wrapperSpan + // Create a child span - it should have wrapperSpan as parent + spanCreatedInCallback = ai.startSpan("child-of-wrapper"); + + if (spanCreatedInCallback) { + spanParentCtx = spanCreatedInCallback.parentSpanContext; + } + + spanCreatedInCallback!.end(); + }); + + // Assert + Assert.ok(spanParentCtx, "Child span should have parent context"); + + // Parent should be wrapperSpan (the withSpan span), not contextSpan + const wrapperSpanCtx = wrapperSpan!.spanContext(); + Assert.equal(spanParentCtx.spanId, wrapperSpanCtx.spanId, + "Child span parent should be the wrapper span from withSpan"); + Assert.notEqual(spanParentCtx.spanId, contextTraceCtx.spanId, + "Child span parent should NOT be the original context span"); + + wrapperSpan!.end(); + contextSpan!.end(); + } + }); + + this.testCase({ + name: "WithSpan: multiple nested spans maintain correct parent-child relationships with trace context", + test: () => { + // Arrange + const rootSpan = this._ai.startSpan("root-span"); + + this._ai.setActiveSpan(rootSpan!); + + const level1Span = this._ai.startSpan("level1-span"); + + let level2SpanParent: any = null; + let level2SpanCtx: any = null; + let level3SpanParent: any = null; + let ai = this._ai; + + // Act - Nested withSpan calls + withSpan(ai.core, level1Span!, function(this: ISpanScope) { + const level1TraceCtx = this.host.getTraceCtx(false); + + // Create level2 span - should have level1 as parent + const level2Span = ai.startSpan("level2-span"); + if (level2Span) { + level2SpanParent = level2Span.parentSpanContext; + level2SpanCtx = level2Span.spanContext(); + } + + withSpan(ai.core, level2Span!, function(this: ISpanScope) { + const level2TraceCtx = this.host.getTraceCtx(false); + + // Create level3 span - should have level2 as parent + const level3Span = ai.startSpan("level3-span"); + if (level3Span) { + level3SpanParent = level3Span.parentSpanContext; + } + + // Verify level3 parent matches level2 trace context + Assert.equal(level3SpanParent.spanId, level2TraceCtx.spanId, + "Level3 span parent should match level2 trace context"); + + level3Span!.end(); + }); + + // Verify level2 parent matches level1 trace context + Assert.equal(level2SpanParent.spanId, level1TraceCtx.spanId, + "Level2 span parent should match level1 trace context"); + + level2Span!.end(); + }); + + // Assert + Assert.ok(level2SpanParent, "Level2 span should have parent context"); + Assert.ok(level2SpanCtx, "Level2 span context should exist"); + Assert.ok(level3SpanParent, "Level3 span should have parent context"); + + // Verify the chain: root -> level1 -> level2 -> level3 + Assert.equal(level2SpanParent.spanId, level1Span!.spanContext().spanId, + "Level2 parent should be level1"); + Assert.equal(level3SpanParent.spanId, level2SpanCtx.spanId, + "Level3 parent should be level2"); + + level1Span!.end(); + rootSpan!.end(); + } + }); + } +} \ No newline at end of file diff --git a/AISKU/Tests/Unit/src/aiskuunittests.ts b/AISKU/Tests/Unit/src/aiskuunittests.ts index 8a53729b0..3088c034d 100644 --- a/AISKU/Tests/Unit/src/aiskuunittests.ts +++ b/AISKU/Tests/Unit/src/aiskuunittests.ts @@ -1,17 +1,44 @@ import { AISKUSizeCheck } from "./AISKUSize.Tests"; -import { ApplicationInsightsTests } from './applicationinsights.e2e.tests'; -import { ApplicationInsightsFetchTests } from './applicationinsights.e2e.fetch.tests'; -import { CdnPackagingChecks } from './CdnPackaging.tests'; -import { GlobalTestHooks } from './GlobalTestHooks.Test'; -import { SanitizerE2ETests } from './sanitizer.e2e.tests'; -import { ValidateE2ETests } from './validate.e2e.tests'; -import { SenderE2ETests } from './sender.e2e.tests'; -import { SnippetInitializationTests } from './SnippetInitialization.Tests'; +import { ApplicationInsightsTests } from "./applicationinsights.e2e.tests"; +import { ApplicationInsightsFetchTests } from "./applicationinsights.e2e.fetch.tests"; +import { CdnPackagingChecks } from "./CdnPackaging.tests"; +import { GlobalTestHooks } from "./GlobalTestHooks.Test"; +import { SanitizerE2ETests } from "./sanitizer.e2e.tests"; +import { ValidateE2ETests } from "./validate.e2e.tests"; +import { SenderE2ETests } from "./sender.e2e.tests"; +import { SnippetInitializationTests } from "./SnippetInitialization.Tests"; import { CdnThrottle} from "./CdnThrottle.tests"; import { ThrottleSentMessage } from "./ThrottleSentMessage.tests"; -import { IAnalyticsConfigTests } from './IAnalyticsConfig.Tests'; +import { IAnalyticsConfigTests } from "./IAnalyticsConfig.Tests"; +import { StartSpanTests } from "./StartSpan.Tests"; +import { UseSpanTests } from "./UseSpan.Tests"; +import { WithSpanTests } from "./WithSpan.Tests"; +import { SpanContextPropagationTests } from "./SpanContextPropagation.Tests"; +import { SpanLifeCycleTests } from "./SpanLifeCycle.Tests" +import { TelemetryItemGenerationTests } from "./TelemetryItemGeneration.Tests"; +import { SpanErrorHandlingTests } from "./SpanErrorHandling.Tests"; +import { SpanUtilsTests } from "./SpanUtils.Tests"; +import { SpanE2ETests } from "./SpanE2E.Tests"; +import { NonRecordingSpanTests } from "./NonRecordingSpan.Tests"; +import { SpanPluginIntegrationTests } from "./SpanPluginIntegration.Tests"; +import { SpanHelperUtilsTests } from "./SpanHelperUtils.Tests"; +import { TraceSuppressionTests } from "./TraceSuppression.Tests"; +import { TraceProviderTests } from "./TraceProvider.Tests"; +import { TraceContextTests } from "./TraceContext.Tests"; +import { OTelInitTests } from "./OTelInit.Tests"; export function runTests() { + new OTelInitTests().registerTests(); + new TraceSuppressionTests().registerTests(); + new SpanErrorHandlingTests().registerTests(); + new SpanUtilsTests().registerTests(); + new SpanE2ETests().registerTests(); + new NonRecordingSpanTests().registerTests(); + new SpanPluginIntegrationTests().registerTests(); + new SpanHelperUtilsTests().registerTests(); + new TraceProviderTests().registerTests(); + new TraceContextTests().registerTests(); + new GlobalTestHooks().registerTests(); new AISKUSizeCheck().registerTests(); new ApplicationInsightsTests().registerTests(); @@ -25,4 +52,10 @@ export function runTests() { new ThrottleSentMessage().registerTests(); new CdnThrottle().registerTests(); new IAnalyticsConfigTests().registerTests(); + new StartSpanTests().registerTests(); + new WithSpanTests().registerTests(); + new UseSpanTests().registerTests(); + new SpanContextPropagationTests().registerTests(); + new SpanLifeCycleTests().registerTests(); + new TelemetryItemGenerationTests().registerTests(); } \ No newline at end of file diff --git a/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts b/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts index 8dc422cf6..09b16247d 100644 --- a/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts +++ b/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts @@ -2,7 +2,7 @@ import { AITestClass, Assert, PollingAssert, EventValidator, TraceValidator, Exc import { SinonSpy } from 'sinon'; import { ApplicationInsights } from '../../../src/applicationinsights-web' import { Sender } from '@microsoft/applicationinsights-channel-js'; -import { IDependencyTelemetry, ContextTagKeys, Event, Trace, Exception, Metric, PageView, PageViewPerformance, RemoteDependencyData, DistributedTracingModes, RequestHeaders, IAutoExceptionTelemetry, BreezeChannelIdentifier, IConfig, EventPersistence } from '@microsoft/applicationinsights-common'; +import { IDependencyTelemetry, ContextTagKeys, Exception, DistributedTracingModes, RequestHeaders, IAutoExceptionTelemetry, BreezeChannelIdentifier, IConfig, EventPersistence, EventDataType, PageViewDataType, TraceDataType, ExceptionDataType, MetricDataType, PageViewPerformanceDataType, RemoteDependencyDataType } from '@microsoft/applicationinsights-common'; import { ITelemetryItem, getGlobal, newId, dumpObj, BaseTelemetryPlugin, IProcessTelemetryContext, __getRegisteredEvents, arrForEach, IConfiguration, ActiveStatus, FeatureOptInMode } from "@microsoft/applicationinsights-core-js"; import { IPropTelemetryContext } from '@microsoft/applicationinsights-properties-js'; import { createAsyncResolvedPromise } from '@nevware21/ts-async'; @@ -11,12 +11,12 @@ import { OfflineChannel } from '@microsoft/applicationinsights-offlinechannel-js import { IStackFrame } from '@microsoft/applicationinsights-common/src/Interfaces/Contracts/IStackFrame'; import { utcNow } from '@nevware21/ts-utils'; -function _checkExpectedFrame(expectedFrame: IStackFrame, actualFrame: IStackFrame, index: number) { +function _checkExpectedFrame(expectedFrame: IStackFrame, actualFrame: IStackFrame, index: number) { Assert.equal(expectedFrame.assembly, actualFrame.assembly, index + ") Assembly is not as expected"); Assert.equal(expectedFrame.fileName, actualFrame.fileName, index + ") FileName is not as expected"); Assert.equal(expectedFrame.line, actualFrame.line, index + ") Line is not as expected"); Assert.equal(expectedFrame.method, actualFrame.method, index + ") Method is not as expected"); - Assert.equal(expectedFrame.level, actualFrame.level, index + ") Level is not as expected"); + Assert.equal(expectedFrame.level, actualFrame.level, index + ") Level is not as expected"); } export class ApplicationInsightsTests extends AITestClass { @@ -40,7 +40,7 @@ export class ApplicationInsightsTests extends AITestClass { private _ai: ApplicationInsights; private _aiName: string = 'AppInsightsSDK'; - private isFetchPolyfill:boolean = false; + private isFetchPolyfill: boolean = false; // Sinon private errorSpy: SinonSpy; @@ -61,7 +61,7 @@ export class ApplicationInsightsTests extends AITestClass { constructor(testName?: string) { super(testName || "ApplicationInsightsTests"); } - + protected _getTestConfig(sessionPrefix: string) { let config: IConfiguration | IConfig = { connectionString: ApplicationInsightsTests._connectionString, @@ -76,7 +76,7 @@ export class ApplicationInsightsTests extends AITestClass { distributedTracingMode: DistributedTracingModes.AI_AND_W3C, samplingPercentage: 50, convertUndefined: "test-value", - disablePageUnloadEvents: [ "beforeunload" ], + disablePageUnloadEvents: ["beforeunload"], extensionConfig: { ["AppInsightsCfgSyncPlugin"]: { //cfgUrl: "" @@ -229,7 +229,7 @@ export class ApplicationInsightsTests extends AITestClass { let onChangeCalled = 0; let handler = this._ai.onCfgChange((details) => { - onChangeCalled ++; + onChangeCalled++; Assert.equal(expectedIkey, details.cfg.instrumentationKey, "Expect the iKey to be set"); Assert.equal(expectedEndpointUrl, details.cfg.endpointUrl, "Expect the endpoint to be set"); Assert.equal(expectedLoggingLevel, details.cfg.diagnosticLogInterval, "Expect the diagnosticLogInterval to be set"); @@ -256,7 +256,7 @@ export class ApplicationInsightsTests extends AITestClass { Assert.equal(3, onChangeCalled, "Expected the onChanged was called"); this.clock.tick(1); Assert.equal(4, onChangeCalled, "Expected the onChanged was called again"); - + // Remove the handler handler.rm(); } @@ -274,15 +274,15 @@ export class ApplicationInsightsTests extends AITestClass { // force unload oriInst.unload(false); } - + if (oriInst && oriInst["dependencies"]) { oriInst["dependencies"].teardown(); } - + this._config = this._getTestConfig(this._sessionPrefix); let csPromise = createAsyncResolvedPromise("InstrumentationKey=testIkey;ingestionendpoint=testUrl"); this._config.connectionString = csPromise; - this._config.initTimeOut= 80000; + this._config.initTimeOut = 80000; this._ctx.csPromise = csPromise; @@ -295,17 +295,17 @@ export class ApplicationInsightsTests extends AITestClass { let core = this._ai.core; let status = core.activeStatus && core.activeStatus(); Assert.equal(status, ActiveStatus.PENDING, "status should be set to pending"); - - + + }].concat(PollingAssert.createPollingAssert(() => { let core = this._ai.core let activeStatus = core.activeStatus && core.activeStatus(); let csPromise = this._ctx.csPromise; let config = this._ai.config; - + if (csPromise.state === "resolved" && activeStatus === ActiveStatus.ACTIVE) { Assert.equal("testIkey", core.config.instrumentationKey, "ikey should be set"); - Assert.equal("testUrl/v2/track", core.config.endpointUrl ,"endpoint shoule be set"); + Assert.equal("testUrl/v2/track", core.config.endpointUrl, "endpoint shoule be set"); config.connectionString = "InstrumentationKey=testIkey1;ingestionendpoint=testUrl1"; this.clock.tick(1); @@ -318,10 +318,10 @@ export class ApplicationInsightsTests extends AITestClass { }, "Wait for promise response" + new Date().toISOString(), 60) as any).concat(PollingAssert.createPollingAssert(() => { let core = this._ai.core let activeStatus = core.activeStatus && core.activeStatus(); - + if (activeStatus === ActiveStatus.ACTIVE) { Assert.equal("testIkey1", core.config.instrumentationKey, "ikey should be set test1"); - Assert.equal("testUrl1/v2/track", core.config.endpointUrl ,"endpoint shoule be set test1"); + Assert.equal("testUrl1/v2/track", core.config.endpointUrl, "endpoint shoule be set test1"); return true; } return false; @@ -340,15 +340,15 @@ export class ApplicationInsightsTests extends AITestClass { // force unload oriInst.unload(false); } - + if (oriInst && oriInst["dependencies"]) { oriInst["dependencies"].teardown(); } - + this._config = this._getTestConfig(this._sessionPrefix); let csPromise = createAsyncResolvedPromise("InstrumentationKey=testIkey;ingestionendpoint=testUrl"); this._config.connectionString = csPromise; - this._config.initTimeOut= 80000; + this._config.initTimeOut = 80000; this._ctx.csPromise = csPromise; @@ -361,22 +361,22 @@ export class ApplicationInsightsTests extends AITestClass { let core = this._ai.core; let status = core.activeStatus && core.activeStatus(); Assert.equal(status, ActiveStatus.PENDING, "status should be set to pending"); - + config.connectionString = "InstrumentationKey=testIkey1;ingestionendpoint=testUrl1"; this.clock.tick(1); status = core.activeStatus && core.activeStatus(); Assert.equal(status, ActiveStatus.ACTIVE, "active status should be set to active in next executing cycle"); // Assert.equal(status, ActiveStatus.PENDING, "status should be set to pending test1"); - - + + }].concat(PollingAssert.createPollingAssert(() => { let core = this._ai.core let activeStatus = core.activeStatus && core.activeStatus(); - + if (activeStatus === ActiveStatus.ACTIVE) { Assert.equal("testIkey", core.config.instrumentationKey, "ikey should be set"); - Assert.equal("testUrl/v2/track", core.config.endpointUrl ,"endpoint shoule be set"); + Assert.equal("testUrl/v2/track", core.config.endpointUrl, "endpoint shoule be set"); return true; } return false; @@ -396,17 +396,17 @@ export class ApplicationInsightsTests extends AITestClass { // force unload oriInst.unload(false); } - + if (oriInst && oriInst["dependencies"]) { oriInst["dependencies"].teardown(); } - + this._config = this._getTestConfig(this._sessionPrefix); let csPromise = createAsyncResolvedPromise("InstrumentationKey=testIkey;ingestionendpoint=testUrl"); this._config.connectionString = csPromise; let offlineChannel = new OfflineChannel(); this._config.channels = [[offlineChannel]]; - this._config.initTimeOut= 80000; + this._config.initTimeOut = 80000; let init = new ApplicationInsights({ @@ -419,21 +419,21 @@ export class ApplicationInsightsTests extends AITestClass { let status = core.activeStatus && core.activeStatus(); Assert.equal(status, ActiveStatus.PENDING, "status should be set to pending"); - + config.connectionString = "InstrumentationKey=testIkey1;ingestionendpoint=testUrl1" this.clock.tick(1); status = core.activeStatus && core.activeStatus(); Assert.equal(status, ActiveStatus.ACTIVE, "active status should be set to active in next executing cycle"); // Assert.equal(status, ActiveStatus.PENDING, "status should be set to pending test1"); - - + + }].concat(PollingAssert.createPollingAssert(() => { let core = this._ai.core let activeStatus = core.activeStatus && core.activeStatus(); - + if (activeStatus === ActiveStatus.ACTIVE) { Assert.equal("testIkey", core.config.instrumentationKey, "ikey should be set"); - Assert.equal("testUrl/v2/track", core.config.endpointUrl ,"endpoint shoule be set"); + Assert.equal("testUrl/v2/track", core.config.endpointUrl, "endpoint shoule be set"); let sendChannel = this._ai.getPlugin(BreezeChannelIdentifier); let offlineChannelPlugin = this._ai.getPlugin("OfflineChannel").plugin; Assert.equal(sendChannel.plugin.isInitialized(), true, "sender is initialized"); @@ -447,7 +447,7 @@ export class ApplicationInsightsTests extends AITestClass { }); - + this.testCaseAsync({ name: "Init: init with cs string, change with cs promise", stepDelay: 100, @@ -472,15 +472,15 @@ export class ApplicationInsightsTests extends AITestClass { status = core.activeStatus && core.activeStatus(); Assert.equal(status, ActiveStatus.ACTIVE, "active status should be set to active in next executing cycle"); //Assert.equal(status, ActiveStatus.PENDING, "status should be set to pending"); - - + + }].concat(PollingAssert.createPollingAssert(() => { let core = this._ai.core let activeStatus = core.activeStatus && core.activeStatus(); - + if (activeStatus === ActiveStatus.ACTIVE) { Assert.equal("testIkey", core.config.instrumentationKey, "ikey should be set"); - Assert.equal("testUrl/v2/track", core.config.endpointUrl ,"endpoint shoule be set"); + Assert.equal("testUrl/v2/track", core.config.endpointUrl, "endpoint shoule be set"); return true; } return false; @@ -499,11 +499,11 @@ export class ApplicationInsightsTests extends AITestClass { // force unload oriInst.unload(false); } - + if (oriInst && oriInst["dependencies"]) { oriInst["dependencies"].teardown(); } - + this._config = this._getTestConfig(this._sessionPrefix); let ikeyPromise = createAsyncResolvedPromise("testIkey"); let endpointPromise = createAsyncResolvedPromise("testUrl"); @@ -512,7 +512,7 @@ export class ApplicationInsightsTests extends AITestClass { this._config.connectionString = null; this._config.instrumentationKey = ikeyPromise; this._config.endpointUrl = endpointPromise; - this._config.initTimeOut= 80000; + this._config.initTimeOut = 80000; @@ -525,16 +525,16 @@ export class ApplicationInsightsTests extends AITestClass { let core = this._ai.core; let status = core.activeStatus && core.activeStatus(); Assert.equal(status, ActiveStatus.PENDING, "status should be set to pending"); - Assert.equal(config.connectionString,null, "connection string shoule be null"); - - + Assert.equal(config.connectionString, null, "connection string shoule be null"); + + }].concat(PollingAssert.createPollingAssert(() => { let core = this._ai.core let activeStatus = core.activeStatus && core.activeStatus(); - + if (activeStatus === ActiveStatus.ACTIVE) { Assert.equal("testIkey", core.config.instrumentationKey, "ikey should be set"); - Assert.equal("testUrl", core.config.endpointUrl ,"endpoint shoule be set"); + Assert.equal("testUrl", core.config.endpointUrl, "endpoint shoule be set"); return true; } return false; @@ -548,21 +548,21 @@ export class ApplicationInsightsTests extends AITestClass { test: () => { let fetchcalled = 0; let overrideFetchFn = (url: string, oncomplete: any, isAutoSync?: boolean) => { - fetchcalled ++; + fetchcalled++; Assert.equal(url, CONFIG_ENDPOINT_URL, "fetch should be called with prod cdn"); }; let config = { instrumentationKey: "testIKey", - extensionConfig:{ + extensionConfig: { ["AppInsightsCfgSyncPlugin"]: { overrideFetchFn: overrideFetchFn } } } as IConfiguration & IConfig; - let ai = new ApplicationInsights({config: config}); + let ai = new ApplicationInsights({ config: config }); ai.loadAppInsights(); - + ai.config.extensionConfig = ai.config.extensionConfig || {}; let extConfig = ai.config.extensionConfig["AppInsightsCfgSyncPlugin"]; Assert.equal(extConfig.cfgUrl, CONFIG_ENDPOINT_URL, "default cdn endpoint should be set"); @@ -570,7 +570,7 @@ export class ApplicationInsightsTests extends AITestClass { let featureOptIn = config.featureOptIn || {}; Assert.equal(featureOptIn["iKeyUsage"].mode, FeatureOptInMode.enable, "ikey message should be turned on"); - + Assert.equal(fetchcalled, 1, "fetch should be called once"); config.extensionConfig = config.extensionConfig || {}; let expectedTimeout = 2000000000; @@ -596,15 +596,15 @@ export class ApplicationInsightsTests extends AITestClass { let config = { instrumentationKey: "testIKey", endpointUrl: "testUrl", - extensionConfig:{ + extensionConfig: { ["AppInsightsCfgSyncPlugin"]: { cfgUrl: "" } }, - extensions:[offlineChannel] + extensions: [offlineChannel] } as IConfiguration & IConfig; - let ai = new ApplicationInsights({config: config}); + let ai = new ApplicationInsights({ config: config }); ai.loadAppInsights(); this.clock.tick(1); @@ -620,7 +620,7 @@ export class ApplicationInsightsTests extends AITestClass { ai["dependencies"].teardown(); } //offlineChannel.teardown(); - + } }); @@ -633,15 +633,15 @@ export class ApplicationInsightsTests extends AITestClass { let config = { instrumentationKey: "testIKey", endpointUrl: "testUrl", - extensionConfig:{ + extensionConfig: { ["AppInsightsCfgSyncPlugin"]: { cfgUrl: "" } }, - channels:[[offlineChannel]] + channels: [[offlineChannel]] } as IConfiguration & IConfig; - let ai = new ApplicationInsights({config: config}); + let ai = new ApplicationInsights({ config: config }); ai.loadAppInsights(); this.clock.tick(1); @@ -651,13 +651,13 @@ export class ApplicationInsightsTests extends AITestClass { Assert.equal(offlineChannelPlugin.isInitialized(), true, "offline channel is initialized"); let urlConfig = offlineChannelPlugin["_getDbgPlgTargets"]()[0]; Assert.ok(urlConfig, "offline url config is initialized"); - + ai.unload(false); if (ai && ai["dependencies"]) { ai["dependencies"].teardown(); } - + } }); @@ -669,15 +669,15 @@ export class ApplicationInsightsTests extends AITestClass { let offlineChannel = new OfflineChannel(); let config = { connectionString: "InstrumentationKey=testIKey", - extensionConfig:{ + extensionConfig: { ["AppInsightsCfgSyncPlugin"]: { cfgUrl: "" } }, - channels:[[offlineChannel]] + channels: [[offlineChannel]] } as IConfiguration & IConfig; - let ai = new ApplicationInsights({config: config}); + let ai = new ApplicationInsights({ config: config }); ai.loadAppInsights(); this.clock.tick(1); @@ -696,7 +696,7 @@ export class ApplicationInsightsTests extends AITestClass { } } }); - + } public addCDNOverrideTests(): void { @@ -766,15 +766,15 @@ export class ApplicationInsightsTests extends AITestClass { headers.forEach((val, key) => { if (key === "content-type") { Assert.deepEqual(val, "text/javascript; charset=utf-8", "should have correct content-type response header"); - headerCnt ++; + headerCnt++; } if (key === "x-ms-meta-aijssdksrc") { Assert.ok(val, "should have sdk src response header"); - headerCnt ++; + headerCnt++; } if (key === "x-ms-meta-aijssdkver") { Assert.ok(val, "should have version number for response header"); - headerCnt ++; + headerCnt++; } }); Assert.equal(headerCnt, 3, "all expected headers should be present"); @@ -819,15 +819,15 @@ export class ApplicationInsightsTests extends AITestClass { headers.forEach((val, key) => { if (key === "content-type") { Assert.deepEqual(val, "text/javascript; charset=utf-8", "should have correct content-type response header"); - headerCnt ++; + headerCnt++; } if (key === "x-ms-meta-aijssdksrc") { Assert.ok(val, "should have sdk src response header"); - headerCnt ++; + headerCnt++; } if (key === "x-ms-meta-aijssdkver") { Assert.ok(val, "should have version number for response header"); - headerCnt ++; + headerCnt++; } }); Assert.equal(headerCnt, 3, "all expected headers should be present"); @@ -855,7 +855,61 @@ export class ApplicationInsightsTests extends AITestClass { if (res.ok) { let val = await res.text(); - Assert.ok(val, "Response text should be returned" ); + Assert.ok(val, "Response text should be returned"); + } else { + Assert.fail("Fetch failed with status: " + dumpObj(res)); + } + } catch (e) { + this._ctx.err = e; + Assert.fail("Fetch Error: " + dumpObj(e)); + } + } + }); + + this.testCase({ + name: "E2E.GenericTests: Fetch Static Web js1 - CDN V3", + useFakeServer: false, + useFakeFetch: false, + fakeFetchAutoRespond: false, + test: async () => { + // Use beta endpoint to pre-test any changes before public V3 cdn + let random = utcNow(); + // Under Cors Mode, Options request will be auto-triggered + try { + let res = await fetch(`https://js1.tst.applicationinsights.io/scripts/b/ai.3.gbl.min.js?${random}`, { + method: "GET" + }); + + if (res.ok) { + let val = await res.text(); + Assert.ok(val, "Response text should be returned"); + } else { + Assert.fail("Fetch failed with status: " + dumpObj(res)); + } + } catch (e) { + this._ctx.err = e; + Assert.fail("Fetch Error: " + dumpObj(e)); + } + } + }); + + this.testCase({ + name: "E2E.GenericTests: Fetch Static Web js2 - CDN V3", + useFakeServer: false, + useFakeFetch: false, + fakeFetchAutoRespond: false, + test: async () => { + // Use beta endpoint to pre-test any changes before public V3 cdn + let random = utcNow(); + // Under Cors Mode, Options request will be auto-triggered + try { + let res = await fetch(`https://js2.tst.applicationinsights.io/scripts/b/ai.3.gbl.min.js?${random}`, { + method: "GET" + }); + + if (res.ok) { + let val = await res.text(); + Assert.ok(val, "Response text should be returned"); } else { Assert.fail("Fetch failed with status: " + dumpObj(res)); } @@ -907,8 +961,8 @@ export class ApplicationInsightsTests extends AITestClass { if (payloadStr.length > 0) { const payload = JSON.parse(payloadStr[0]); const data = payload.data; - Assert.ok( payload && payload.iKey); - Assert.equal( ApplicationInsightsTests._instrumentationKey,payload.iKey,"payload ikey is not set correctly" ); + Assert.ok(payload && payload.iKey); + Assert.equal(ApplicationInsightsTests._instrumentationKey, payload.iKey, "payload ikey is not set correctly"); Assert.ok(data && data.baseData && data.baseData.properties["prop1"]); Assert.ok(data && data.baseData && data.baseData.measurements["measurement1"]); } @@ -924,8 +978,8 @@ export class ApplicationInsightsTests extends AITestClass { if (payloadStr.length > 0) { const payload = JSON.parse(payloadStr[0]); const data = payload.data; - Assert.ok( payload && payload.iKey); - Assert.equal( ApplicationInsightsTests._instrumentationKey,payload.iKey,"payload ikey is not set correctly" ); + Assert.ok(payload && payload.iKey); + Assert.equal(ApplicationInsightsTests._instrumentationKey, payload.iKey, "payload ikey is not set correctly"); Assert.ok(data && data.baseData && data.baseData.properties["prop1"]); Assert.ok(data && data.baseData && data.baseData.measurements["measurement1"]); } @@ -982,7 +1036,7 @@ export class ApplicationInsightsTests extends AITestClass { error: e, evt: null } as IAutoExceptionTelemetry; - + exception = e; this._ai.trackException({ exception: autoTelemetry }); } @@ -1008,7 +1062,7 @@ export class ApplicationInsightsTests extends AITestClass { error: e, evt: null } as IAutoExceptionTelemetry; - + exception = e; this._ai.trackException({ exception: autoTelemetry }, { custom: "custom value" }); } @@ -1034,7 +1088,7 @@ export class ApplicationInsightsTests extends AITestClass { error: e.toString(), evt: null } as IAutoExceptionTelemetry; - + exception = e; this._ai.trackException({ exception: autoTelemetry }); } @@ -1060,7 +1114,7 @@ export class ApplicationInsightsTests extends AITestClass { error: undefined, evt: null } as IAutoExceptionTelemetry; - + try { exception = e; this._ai.trackException({ exception: autoTelemetry }); @@ -1092,7 +1146,7 @@ export class ApplicationInsightsTests extends AITestClass { error: undefined, evt: null } as IAutoExceptionTelemetry; - + try { exception = e; this._ai.trackException({ exception: autoTelemetry }, { custom: "custom value" }); @@ -1156,7 +1210,7 @@ export class ApplicationInsightsTests extends AITestClass { name: 'E2E.GenericTests: trackException will keep id from the original exception', stepDelay: 1, steps: [() => { - this._ai.trackException({id:"testId", error: new Error("test local exception"), severityLevel: 3}); + this._ai.trackException({ id: "testId", error: new Error("test local exception"), severityLevel: 3 }); }].concat(this.asserts(1)).concat(() => { const payloadStr: string[] = this.getPayloadMessages(this.successSpy); if (payloadStr.length > 0) { @@ -1214,7 +1268,7 @@ export class ApplicationInsightsTests extends AITestClass { steps: [() => { let errObj = { name: "E2E.GenericTests", - reason:{ + reason: { message: "Test_Error_Throwing_Inside_UseCallback", stack: "Error: Test_Error_Throwing_Inside_UseCallback\n" + "at http://localhost:3000/static/js/main.206f4846.js:2:296748\n" + // Anonymous function with no function name attribution (firefox/ios) @@ -1234,7 +1288,7 @@ export class ApplicationInsightsTests extends AITestClass { " Line 11 of inline#1 script in http://localhost:3000/static/js/main.206f4846.js:2:296748\n" + // With Line 11 of inline#1 script attribution " Line 68 of inline#2 script in file://localhost/teststack.html\n" + // With Line 68 of inline#2 script attribution "at Function.Module._load (module.js:407:3)\n" + - " at Function.Module.runMain (module.js:575:10)\n"+ + " at Function.Module.runMain (module.js:575:10)\n" + " at startup (node.js:159:18)\n" + "at Global code (http://example.com/stacktrace.js:11:1)\n" + "at Object.Module._extensions..js (module.js:550:10)\n" + @@ -1307,7 +1361,7 @@ export class ApplicationInsightsTests extends AITestClass { Assert.equal(ex.parsedStack.length, 29); for (let lp = 0; lp < ex.parsedStack.length; lp++) { _checkExpectedFrame(expectedParsedStack[lp], ex.parsedStack[lp], lp); - } + } Assert.ok(baseData.properties, "Has BaseData properties"); Assert.equal(baseData.properties.custom, "custom value"); @@ -1323,13 +1377,13 @@ export class ApplicationInsightsTests extends AITestClass { stepDelay: 1, steps: [() => { let message = "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:\n" + - "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + - "2. You might be breaking the Rules of Hooks\n" + - "3. You might have more than one copy of React in the same app\n" + - "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem."; + "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" + + "2. You might be breaking the Rules of Hooks\n" + + "3. You might have more than one copy of React in the same app\n" + + "See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem."; let errObj = { typeName: "Error", - reason:{ + reason: { message: "Error: " + message, stack: "Error: " + message + "\n" + " at Object.throwInvalidHookError (https://localhost:44365/static/js/bundle.js:201419:13)\n" + @@ -1384,7 +1438,7 @@ export class ApplicationInsightsTests extends AITestClass { Assert.equal(ex.parsedStack.length, 10); for (let lp = 0; lp < ex.parsedStack.length; lp++) { _checkExpectedFrame(expectedParsedStack[lp], ex.parsedStack[lp], lp); - } + } Assert.ok(baseData.properties, "Has BaseData properties"); Assert.equal(baseData.properties.custom, "custom value"); @@ -1402,7 +1456,7 @@ export class ApplicationInsightsTests extends AITestClass { () => { console.log("* calling trackMetric " + new Date().toISOString()); for (let i = 0; i < 100; i++) { - this._ai.trackMetric({ name: "test" + i, average: Math.round(100 * Math.random()), min: 1, max: i+1, stdDev: 10.0 * Math.random() }); + this._ai.trackMetric({ name: "test" + i, average: Math.round(100 * Math.random()), min: 1, max: i + 1, stdDev: 10.0 * Math.random() }); } console.log("* done calling trackMetric " + new Date().toISOString()); } @@ -1432,21 +1486,21 @@ export class ApplicationInsightsTests extends AITestClass { this._ai.trackPageView(); // sends 2 } ] - .concat(this.asserts(2)) - .concat(() => { + .concat(this.asserts(2)) + .concat(() => { - const payloadStr: string[] = this.getPayloadMessages(this.successSpy); - if (payloadStr.length > 0) { - const payload = JSON.parse(payloadStr[0]); - const data = payload.data; - Assert.ok(data.baseData.id, "pageView id is defined"); - Assert.ok(data.baseData.id.length > 0); - Assert.ok(payload.tags["ai.operation.id"]); - Assert.equal(data.baseData.id, payload.tags["ai.operation.id"], "pageView id matches current operation id"); - } else { - Assert.ok(false, "successSpy not called"); - } - }) + const payloadStr: string[] = this.getPayloadMessages(this.successSpy); + if (payloadStr.length > 0) { + const payload = JSON.parse(payloadStr[0]); + const data = payload.data; + Assert.ok(data.baseData.id, "pageView id is defined"); + Assert.ok(data.baseData.id.length > 0); + Assert.ok(payload.tags["ai.operation.id"]); + Assert.equal(data.baseData.id, payload.tags["ai.operation.id"], "pageView id matches current operation id"); + } else { + Assert.ok(false, "successSpy not called"); + } + }) }); this.testCaseAsync({ @@ -1546,20 +1600,20 @@ export class ApplicationInsightsTests extends AITestClass { name: 'E2E.GenericTests: undefined properties are replaced by customer defined value with config convertUndefined.', stepDelay: 1, steps: [() => { - this._ai.trackPageView({ name: 'pageview', properties: { 'prop1': 'val1' }}); + this._ai.trackPageView({ name: 'pageview', properties: { 'prop1': 'val1' } }); this._ai.trackEvent({ name: 'event', properties: { 'prop2': undefined } }); }].concat(this.asserts(3)).concat(() => { const payloadStr: string[] = this.getPayloadMessages(this.successSpy); for (let i = 0; i < payloadStr.length; i++) { - const payload = JSON.parse(payloadStr[i]);const baseType = payload.data.baseType; + const payload = JSON.parse(payloadStr[i]); const baseType = payload.data.baseType; // Make the appropriate assersion depending on the baseType switch (baseType) { - case Event.dataType: + case EventDataType: const eventData = payload.data; Assert.ok(eventData && eventData.baseData && eventData.baseData.properties['prop2']); Assert.equal(eventData.baseData.properties['prop2'], 'test-value'); break; - case PageView.dataType: + case PageViewDataType: const pageViewData = payload.data; Assert.ok(pageViewData && pageViewData.baseData && pageViewData.baseData.properties['prop1']); Assert.equal(pageViewData.baseData.properties['prop1'], 'val1'); @@ -1598,7 +1652,7 @@ export class ApplicationInsightsTests extends AITestClass { steps: [ () => { const xhr = new XMLHttpRequest(); - xhr.open('GET', 'https://httpbin.org/status/200'); + xhr.open('GET', 'https://localhost:9001/AISKU'); xhr.send(); Assert.ok(true); } @@ -1613,7 +1667,7 @@ export class ApplicationInsightsTests extends AITestClass { fakeFetchAutoRespond: true, steps: [ () => { - fetch('https://httpbin.org/status/200', { method: 'GET', headers: { 'header': 'value'} }); + fetch('https://httpbin.org/status/200', { method: 'GET', headers: { 'header': 'value' } }); Assert.ok(true, "fetch monitoring is instrumented"); }, () => { @@ -1628,7 +1682,7 @@ export class ApplicationInsightsTests extends AITestClass { .concat(() => { let args = []; this.trackSpy.args.forEach(call => { - let message = call[0].baseData.message||""; + let message = call[0].baseData.message || ""; // Ignore the internal SendBrowserInfoOnUserInit message (Only occurs when running tests in a browser) if (message.indexOf("AI (Internal): 72 ") == -1) { args.push(call[0]); @@ -1682,23 +1736,23 @@ export class ApplicationInsightsTests extends AITestClass { this._ai.trackEvent({ name: "Custom event via addTelemetryInitializer" }); } ] - .concat(this.asserts(1, false, false)) - .concat(PollingAssert.createPollingAssert(() => { - const payloadStr: string[] = this.getPayloadMessages(this.successSpy); - if (payloadStr.length) { - const payload = JSON.parse(payloadStr[0]); + .concat(this.asserts(1, false, false)) + .concat(PollingAssert.createPollingAssert(() => { + const payloadStr: string[] = this.getPayloadMessages(this.successSpy); + if (payloadStr.length) { + const payload = JSON.parse(payloadStr[0]); Assert.equal(1, payloadStr.length, 'Only 1 track item is sent - ' + payload.name); Assert.ok(payload); - if (payload && payload.tags) { - const tagResult: string = payload.tags && payload.tags[this.tagKeys.cloudName]; - const tagExpect: string = 'my.custom.cloud.name'; - Assert.equal(tagResult, tagExpect, 'telemetryinitializer tag override successful'); - return true; + if (payload && payload.tags) { + const tagResult: string = payload.tags && payload.tags[this.tagKeys.cloudName]; + const tagExpect: string = 'my.custom.cloud.name'; + Assert.equal(tagResult, tagExpect, 'telemetryinitializer tag override successful'); + return true; + } + return false; } - return false; - } - }, 'Set custom tags') as any) + }, 'Set custom tags') as any) }); this.testCaseAsync({ @@ -1707,28 +1761,28 @@ export class ApplicationInsightsTests extends AITestClass { steps: [ () => { this._ai.addTelemetryInitializer((item: ITelemetryItem) => { - item.tags.push({[this.tagKeys.cloudName]: "my.shim.cloud.name"}); + item.tags.push({ [this.tagKeys.cloudName]: "my.shim.cloud.name" }); }); this._ai.trackEvent({ name: "Custom event" }); } ] - .concat(this.asserts(1)) - .concat(PollingAssert.createPollingAssert(() => { - const payloadStr: string[] = this.getPayloadMessages(this.successSpy); - if (payloadStr.length > 0) { - Assert.equal(1, payloadStr.length, 'Only 1 track item is sent'); - const payload = JSON.parse(payloadStr[0]); - Assert.ok(payload); + .concat(this.asserts(1)) + .concat(PollingAssert.createPollingAssert(() => { + const payloadStr: string[] = this.getPayloadMessages(this.successSpy); + if (payloadStr.length > 0) { + Assert.equal(1, payloadStr.length, 'Only 1 track item is sent'); + const payload = JSON.parse(payloadStr[0]); + Assert.ok(payload); - if (payload && payload.tags) { - const tagResult: string = payload.tags && payload.tags[this.tagKeys.cloudName]; - const tagExpect: string = 'my.shim.cloud.name'; - Assert.equal(tagResult, tagExpect, 'telemetryinitializer tag override successful'); - return true; + if (payload && payload.tags) { + const tagResult: string = payload.tags && payload.tags[this.tagKeys.cloudName]; + const tagExpect: string = 'my.shim.cloud.name'; + Assert.equal(tagResult, tagExpect, 'telemetryinitializer tag override successful'); + return true; + } + return false; } - return false; - } - }, 'Set custom tags') as any) + }, 'Set custom tags') as any) }); this.testCaseAsync({ @@ -1739,41 +1793,41 @@ export class ApplicationInsightsTests extends AITestClass { this._ai.addTelemetryInitializer((item: ITelemetryItem) => { item.tags[this.tagKeys.cloudName] = "my.custom.cloud.name"; item.tags[this.tagKeys.locationCity] = "my.custom.location.city"; - item.tags.push({[this.tagKeys.locationCountry]: "my.custom.location.country"}); - item.tags.push({[this.tagKeys.operationId]: "my.custom.operation.id"}); + item.tags.push({ [this.tagKeys.locationCountry]: "my.custom.location.country" }); + item.tags.push({ [this.tagKeys.operationId]: "my.custom.operation.id" }); }); this._ai.trackEvent({ name: "Custom event via shimmed addTelemetryInitializer" }); } ] - .concat(this.asserts(1)) - .concat(PollingAssert.createPollingAssert(() => { - const payloadStr: string[] = this.getPayloadMessages(this.successSpy); - if (payloadStr.length > 0) { - const payload = JSON.parse(payloadStr[0]); - Assert.equal(1, payloadStr.length, 'Only 1 track item is sent - ' + payload.name); - if (payloadStr.length > 1) { - this.dumpPayloadMessages(this.successSpy); - } - Assert.ok(payload); - - if (payload && payload.tags) { - const tagResult1: string = payload.tags && payload.tags[this.tagKeys.cloudName]; - const tagExpect1: string = 'my.custom.cloud.name'; - Assert.equal(tagResult1, tagExpect1, 'telemetryinitializer tag override successful'); - const tagResult2: string = payload.tags && payload.tags[this.tagKeys.locationCity]; - const tagExpect2: string = 'my.custom.location.city'; - Assert.equal(tagResult2, tagExpect2, 'telemetryinitializer tag override successful'); - const tagResult3: string = payload.tags && payload.tags[this.tagKeys.locationCountry]; - const tagExpect3: string = 'my.custom.location.country'; - Assert.equal(tagResult3, tagExpect3, 'telemetryinitializer tag override successful'); - const tagResult4: string = payload.tags && payload.tags[this.tagKeys.operationId]; - const tagExpect4: string = 'my.custom.operation.id'; - Assert.equal(tagResult4, tagExpect4, 'telemetryinitializer tag override successful'); - return true; + .concat(this.asserts(1)) + .concat(PollingAssert.createPollingAssert(() => { + const payloadStr: string[] = this.getPayloadMessages(this.successSpy); + if (payloadStr.length > 0) { + const payload = JSON.parse(payloadStr[0]); + Assert.equal(1, payloadStr.length, 'Only 1 track item is sent - ' + payload.name); + if (payloadStr.length > 1) { + this.dumpPayloadMessages(this.successSpy); + } + Assert.ok(payload); + + if (payload && payload.tags) { + const tagResult1: string = payload.tags && payload.tags[this.tagKeys.cloudName]; + const tagExpect1: string = 'my.custom.cloud.name'; + Assert.equal(tagResult1, tagExpect1, 'telemetryinitializer tag override successful'); + const tagResult2: string = payload.tags && payload.tags[this.tagKeys.locationCity]; + const tagExpect2: string = 'my.custom.location.city'; + Assert.equal(tagResult2, tagExpect2, 'telemetryinitializer tag override successful'); + const tagResult3: string = payload.tags && payload.tags[this.tagKeys.locationCountry]; + const tagExpect3: string = 'my.custom.location.country'; + Assert.equal(tagResult3, tagExpect3, 'telemetryinitializer tag override successful'); + const tagResult4: string = payload.tags && payload.tags[this.tagKeys.operationId]; + const tagExpect4: string = 'my.custom.operation.id'; + Assert.equal(tagResult4, tagExpect4, 'telemetryinitializer tag override successful'); + return true; + } + return false; } - return false; - } - }, 'Set custom tags') as any) + }, 'Set custom tags') as any) }); this.testCaseAsync({ @@ -1791,7 +1845,7 @@ export class ApplicationInsightsTests extends AITestClass { let payloadStr = this.getPayloadMessages(this.successSpy); if (payloadStr.length > 0) { let payloadEvents = payloadStr.length; - let thePayload:string = payloadStr[0]; + let thePayload: string = payloadStr[0]; if (payloadEvents !== 1) { // Only 1 track should be sent @@ -1932,7 +1986,7 @@ export class ApplicationInsightsTests extends AITestClass { this.testCase({ name: 'iKey replacement: envelope will use the non-empty iKey defined in track method', test: () => { - this._ai.trackEvent({ name: 'event1', properties: { "prop1": "value1" }, measurements: { "measurement1": 200 }, iKey:"1a6933ad-aaaa-aaaa-aaaa-000000000000" }); + this._ai.trackEvent({ name: 'event1', properties: { "prop1": "value1" }, measurements: { "measurement1": 200 }, iKey: "1a6933ad-aaaa-aaaa-aaaa-000000000000" }); Assert.ok(this.envelopeConstructorSpy.called); const envelope = this.envelopeConstructorSpy.returnValues[0]; Assert.equal(envelope.iKey, "1a6933ad-aaaa-aaaa-aaaa-000000000000", "trackEvent iKey is replaced"); @@ -1942,7 +1996,7 @@ export class ApplicationInsightsTests extends AITestClass { this.testCase({ name: 'iKey replacement: envelope will use the config iKey if defined ikey in track method is empty', test: () => { - this._ai.trackEvent({ name: 'event1', properties: { "prop1": "value1" }, measurements: { "measurement1": 200 }, iKey:"" }); + this._ai.trackEvent({ name: 'event1', properties: { "prop1": "value1" }, measurements: { "measurement1": 200 }, iKey: "" }); Assert.ok(this.envelopeConstructorSpy.called); const envelope = this.envelopeConstructorSpy.returnValues[0]; Assert.equal(envelope.iKey, ApplicationInsightsTests._instrumentationKey, "trackEvent iKey should not be replaced"); @@ -1961,7 +2015,7 @@ export class ApplicationInsightsTests extends AITestClass { } } } - private asserts: any = (expectedCount: number, includeInit:boolean = false, doBoilerPlate:boolean = true) => [ + private asserts: any = (expectedCount: number, includeInit: boolean = false, doBoilerPlate: boolean = true) => [ () => { const message = "polling: " + new Date().toISOString(); Assert.ok(true, message); @@ -1991,21 +2045,22 @@ export class ApplicationInsightsTests extends AITestClass { if (currentCount === expectedCount && !!this._ai.context.appId()) { const payload = JSON.parse(payloadStr[0]); const baseType = payload.data.baseType; + // call the appropriate Validate depending on the baseType switch (baseType) { - case Event.dataType: + case EventDataType: return EventValidator.EventValidator.Validate(payload, baseType); - case Trace.dataType: + case TraceDataType: return TraceValidator.TraceValidator.Validate(payload, baseType); - case Exception.dataType: + case ExceptionDataType: return ExceptionValidator.ExceptionValidator.Validate(payload, baseType); - case Metric.dataType: + case MetricDataType: return MetricValidator.MetricValidator.Validate(payload, baseType); - case PageView.dataType: + case PageViewDataType: return PageViewValidator.PageViewValidator.Validate(payload, baseType); - case PageViewPerformance.dataType: + case PageViewPerformanceDataType: return PageViewPerformanceValidator.PageViewPerformanceValidator.Validate(payload, baseType); - case RemoteDependencyData.dataType: + case RemoteDependencyDataType: return RemoteDepdencyValidator.RemoteDepdencyValidator.Validate(payload, baseType); default: @@ -2022,9 +2077,9 @@ export class ApplicationInsightsTests extends AITestClass { class CustomTestError extends Error { constructor(message = "") { - super(message); - this.name = "CustomTestError"; - this.message = message + " -- test error."; + super(message); + this.name = "CustomTestError"; + this.message = message + " -- test error."; } } diff --git a/AISKU/examples/span-usage-example.ts b/AISKU/examples/span-usage-example.ts new file mode 100644 index 000000000..500136ac2 --- /dev/null +++ b/AISKU/examples/span-usage-example.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Example showing how to use the ApplicationInsights span functionality + * with the provider pattern. + */ + +import { ApplicationInsights, AppInsightsTraceProvider } from "@microsoft/applicationinsights-web"; + +// Initialize ApplicationInsights +const appInsights = new ApplicationInsights({ + config: { + connectionString: "YOUR_CONNECTION_STRING_HERE" + } +}); +appInsights.loadAppInsights(); + +// Register the trace provider with the core +const traceProvider = new AppInsightsTraceProvider(); +appInsights.appInsightsCore?.setTracer(traceProvider); + +// Example usage +function exampleSpanUsage() { + // Start a span using the core's provider pattern + const span = appInsights.appInsightsCore?.startSpan("example-operation", { + kind: OTelSpanKind.CLIENT, + attributes: { + "operation.name": "example", + "user.id": "12345" + } + }); + + if (span) { + try { + // Do some work... + span.setAttribute("result", "success"); + span.setAttribute("duration", 100); + + // Create a child span + const childSpan = appInsights.appInsightsCore?.startSpan("child-operation", { + kind: SpanKind.INTERNAL, + startTime: Date.now() + }, span.spanContext()); + + if (childSpan) { + // Do child work... + childSpan.setAttribute("child.data", "value"); + childSpan.end(); + } + + } catch (error) { + span.setAttribute("error", true); + span.setAttribute("error.message", error.message); + } finally { + span.end(); + } + } +} + +// Example of checking if trace provider is available +function checkTraceProviderAvailability() { + const provider = appInsights.appInsightsCore?.getTraceProvider(); + if (provider && provider.isAvailable()) { + console.log(`Trace provider available: ${provider.getProviderId()}`); + exampleSpanUsage(); + } else { + console.log("No trace provider available"); + } +} + +export { exampleSpanUsage, checkTraceProviderAvailability }; diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index ff35ec63e..c86ffd1f0 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -14,12 +14,13 @@ import { } from "@microsoft/applicationinsights-common"; import { AppInsightsCore, FeatureOptInMode, IAppInsightsCore, IChannelControls, IConfigDefaults, IConfiguration, ICookieMgr, ICustomProperties, - IDiagnosticLogger, IDistributedTraceContext, IDynamicConfigHandler, ILoadedPlugin, INotificationManager, IPlugin, - ITelemetryInitializerHandler, ITelemetryItem, ITelemetryPlugin, ITelemetryUnloadState, IUnloadHook, UnloadHandler, WatcherFunction, - _eInternalMessageId, _throwInternal, addPageHideEventListener, addPageUnloadEventListener, cfgDfMerge, cfgDfValidate, - createDynamicConfig, createProcessTelemetryContext, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, - isFeatureEnabled, isFunction, isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, proxyAssign, proxyFunctions, - removePageHideEventListener, removePageUnloadEventListener + IDiagnosticLogger, IDistributedTraceContext, IDynamicConfigHandler, ILoadedPlugin, INotificationManager, IOTelApi, IOTelSpanOptions, + IPlugin, IReadableSpan, ISpanScope, ITelemetryInitializerHandler, ITelemetryItem, ITelemetryPlugin, ITelemetryUnloadState, ITraceApi, + ITraceProvider, IUnloadHook, UnloadHandler, WatcherFunction, _eInternalMessageId, _throwInternal, addPageHideEventListener, + addPageUnloadEventListener, cfgDfMerge, cfgDfValidate, createDynamicConfig, createOTelApi, createProcessTelemetryContext, + createTraceProvider, createUniqueNamespace, doPerf, eLoggingSeverity, hasDocument, hasWindow, isArray, isFeatureEnabled, isFunction, + isNullOrUndefined, isReactNative, isString, mergeEvtNamespace, onConfigChange, proxyAssign, proxyFunctions, removePageHideEventListener, + removePageUnloadEventListener, useSpan } from "@microsoft/applicationinsights-core-js"; import { AjaxPlugin as DependenciesPlugin, DependencyInitializerFunction, DependencyListenerFunction, IDependencyInitializerHandler, @@ -27,24 +28,39 @@ import { } from "@microsoft/applicationinsights-dependencies-js"; import { PropertiesPlugin } from "@microsoft/applicationinsights-properties-js"; import { IPromise, createPromise, createSyncPromise, doAwaitResponse } from "@nevware21/ts-async"; -import { arrForEach, arrIndexOf, isPromiseLike, objDefine, objForEachKey, strIndexOf, throwUnsupported } from "@nevware21/ts-utils"; +import { + ICachedValue, arrForEach, arrIndexOf, dumpObj, getDeferred, isPromiseLike, objDefine, objForEachKey, strIndexOf, throwUnsupported +} from "@nevware21/ts-utils"; import { IApplicationInsights } from "./IApplicationInsights"; import { CONFIG_ENDPOINT_URL, STR_ADD_TELEMETRY_INITIALIZER, STR_CLEAR_AUTHENTICATED_USER_CONTEXT, STR_EVT_NAMESPACE, STR_GET_COOKIE_MGR, STR_GET_PLUGIN, STR_POLL_INTERNAL_LOGS, STR_SET_AUTHENTICATED_USER_CONTEXT, STR_SNIPPET, STR_START_TRACK_EVENT, STR_START_TRACK_PAGE, STR_STOP_TRACK_EVENT, STR_STOP_TRACK_PAGE, STR_TRACK_DEPENDENCY_DATA, STR_TRACK_EVENT, STR_TRACK_EXCEPTION, STR_TRACK_METRIC, - STR_TRACK_PAGE_VIEW, STR_TRACK_TRACE + STR_TRACK_PAGE_VIEW, STR_TRACK_TRACE, UNDEFINED_VALUE } from "./InternalConstants"; import { Snippet } from "./Snippet"; +import { createTelemetryItemFromSpan } from "./internal/trace/spanUtils"; export { IRequestHeaders }; let _internalSdkSrc: string; +const STR_DEPENDENCIES = "dependencies"; +const STR_PROPERTIES = "properties"; +const STR_SNIPPET_VERSION = "_snippetVersion"; +const STR_APP_INSIGHTS_NEW = "appInsightsNew"; +const STR_GET_SKU_DEFAULTS = "getSKUDefaults"; + // This is an exclude list of properties that should not be updated during initialization // They include a combination of private and internal property names const _ignoreUpdateSnippetProperties = [ - STR_SNIPPET, "dependencies", "properties", "_snippetVersion", "appInsightsNew", "getSKUDefaults" + STR_SNIPPET, STR_DEPENDENCIES, STR_PROPERTIES, STR_SNIPPET_VERSION, STR_APP_INSIGHTS_NEW, STR_GET_SKU_DEFAULTS, "trace", "otelApi" +]; + +// This is an exclude list of properties that should not be proxied to the snippet +// They include a combination of private and internal property names +const _ignoreProxyAssignProperties = [ + STR_SNIPPET, STR_DEPENDENCIES, STR_PROPERTIES, STR_SNIPPET_VERSION, STR_APP_INSIGHTS_NEW, STR_GET_SKU_DEFAULTS ]; const IKEY_USAGE = "iKeyUsage"; @@ -52,8 +68,6 @@ const CDN_USAGE = "CdnUsage"; const SDK_LOADER_VER = "SdkLoaderVer"; const ZIP_PAYLOAD = "zipPayload"; -const UNDEFINED_VALUE: undefined = undefined; - const default_limit = { samplingRate: 100, maxSendNumber: 1 @@ -122,12 +136,25 @@ function _parseCs(config: IConfiguration & IConfig, configCs: string | IPromise< }); } +function _initOTel(sku: AppInsightsSku, traceName: string, onEnd: (span: IReadableSpan) => void): ICachedValue { + let otelApi: ICachedValue = getDeferred(createOTelApi, [{ + host: sku + }]); + + // Create the initial default traceProvider + sku.core.setTraceProvider(getDeferred(() => { + return createTraceProvider(sku, traceName, otelApi.v, onEnd); + })); + + return otelApi; +} + /** * Application Insights API * @group Entrypoint * @group Classes */ -export class AppInsightsSku implements IApplicationInsights { +export class AppInsightsSku implements IApplicationInsights { public snippet: Snippet; /** @@ -151,6 +178,10 @@ export class AppInsightsSku implements IApplicationInsights { */ public readonly pluginVersionString: string; + public readonly trace: ITraceApi; + + public readonly otelApi: IOTelApi; + constructor(snippet: Snippet) { // NOTE!: DON'T set default values here, instead set them in the _initDefaults() function as it is also called during teardown() let dependencies: DependenciesPlugin; @@ -167,6 +198,7 @@ export class AppInsightsSku implements IApplicationInsights { let _iKeySentMessage: boolean; let _cdnSentMessage: boolean; let _sdkVerSentMessage: boolean; + let _otelApi: ICachedValue; dynamicProto(AppInsightsSku, this, (_self) => { _initDefaults(); @@ -181,7 +213,7 @@ export class AppInsightsSku implements IApplicationInsights { objDefine(_self, key, { g: () => { if (_core) { - return _core[key]; + return (_core as any)[key]; } return null; @@ -209,12 +241,25 @@ export class AppInsightsSku implements IApplicationInsights { _sender = new Sender(); _core = new AppInsightsCore(); + objDefine(_self, "core", { g: () => { return _core; } }); + objDefine(_self, "otelApi", { + g: function() { + return _otelApi ? _otelApi.v : null; + } + }); + + objDefine(_self, "trace", { + g: function() { + return _otelApi ? _otelApi.v.trace : null; + } + }); + // Will get recalled if any referenced values are changed _addUnloadHook(onConfigChange(cfgHandler, () => { let configCs = _config.connectionString; @@ -314,8 +359,6 @@ export class AppInsightsSku implements IApplicationInsights { } }); }; - - _self.loadAppInsights = (legacyMode: boolean = false, logger?: IDiagnosticLogger, notificationManager?: INotificationManager): IApplicationInsights => { if (legacyMode) { @@ -338,8 +381,8 @@ export class AppInsightsSku implements IApplicationInsights { !isFunction(value) && field && field[0] !== "_" && // Don't copy "internal" values arrIndexOf(_ignoreUpdateSnippetProperties, field) === -1) { - if (snippet[field] !== value) { - snippet[field as string] = value; + if ((snippet as any)[field] !== value) { + (snippet as any)[field as string] = value; } } }); @@ -349,9 +392,14 @@ export class AppInsightsSku implements IApplicationInsights { doPerf(_self.core, () => "AISKU.loadAppInsights", () => { // initialize core _core.initialize(_config, [ _sender, properties, dependencies, _analyticsPlugin, _cfgSyncPlugin], logger, notificationManager); + + // Initialize the initial OTel API + _otelApi = _initOTel(_self, "aisku", _onEnd); + objDefine(_self, "context", { g: () => properties.context }); + if (!_throttleMgr){ _throttleMgr = new ThrottleMgr(_core); } @@ -402,7 +450,7 @@ export class AppInsightsSku implements IApplicationInsights { // Note: This must be called before loadAppInsights is called proxyAssign(snippet, _self, (name: string) => { // Not excluding names prefixed with "_" as we need to proxy some functions like _onError - return name && arrIndexOf(_ignoreUpdateSnippetProperties, name) === -1; + return name && arrIndexOf(_ignoreProxyAssignProperties, name) === -1; }); }; @@ -528,7 +576,21 @@ export class AppInsightsSku implements IApplicationInsights { if (!unloadDone) { unloadDone = true; + // Reset OTel API to clean up all trace state before unloading core + if (_core) { + // Clear the trace provider to stop any active spans + _core.setTraceProvider(null); + + // Reset the OTel API instances - this will be recreated on next init + if (_otelApi) { + _otelApi.v.shutdown(); + } + + _otelApi = null; + } + _initDefaults(); + unloadComplete && unloadComplete(unloadState); } } @@ -571,9 +633,16 @@ export class AppInsightsSku implements IApplicationInsights { "addPlugin", STR_EVT_NAMESPACE, "addUnloadCb", - "getTraceCtx", "updateCfg", - "onCfgChange" + "onCfgChange", + // ITraceHost Proxy + "getTraceCtx", + "setTraceCtx", + "startSpan", + "getActiveSpan", + "setActiveSpan", + "setTraceProvider", + "getTraceProvider" ]); proxyFunctions(_self, () => { @@ -583,7 +652,30 @@ export class AppInsightsSku implements IApplicationInsights { STR_SET_AUTHENTICATED_USER_CONTEXT, STR_CLEAR_AUTHENTICATED_USER_CONTEXT ]); - + + // Handle span end event - create telemetry from span data + function _onEnd(span: IReadableSpan) { + if (_otelApi && span && span.isRecording() && !_otelApi.v.cfg.traceCfg.suppressTracing) { + + // Flip this span to be the "current" span during processing, so any telemetry created during the span processing + // is associated with this span + useSpan(_core, span, () => { + try { + // Create trace telemetry for the span + let telemetryItem: ITelemetryItem = createTelemetryItemFromSpan(_core, span); + if (telemetryItem) { + _self.core.track(telemetryItem); + } + } catch (error) { + // Log any errors during trace processing but don't let them break the span lifecycle + _throwInternal(_core.logger, eLoggingSeverity.WARNING, + _eInternalMessageId.TelemetryInitializerFailed, + "Error processing span - " + dumpObj(error)); + } + }); + } + } + // Using a function to support the dynamic adding / removal of plugins, so this will always return the current value function _getCurrentDependencies() { return dependencies; @@ -798,8 +890,8 @@ export class AppInsightsSku implements IApplicationInsights { /** * Initialize this instance of ApplicationInsights - * @returns {IApplicationInsights} * @param legacyMode - MUST always be false, it is no longer supported from v3.x onwards + * @returns The initialized {@link IApplicationInsights} instance */ public loadAppInsights(legacyMode: boolean = false, logger?: IDiagnosticLogger, notificationManager?: INotificationManager): IApplicationInsights { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging @@ -930,11 +1022,18 @@ export class AppInsightsSku implements IApplicationInsights { /** * Gets the current distributed trace context for this instance if available */ - public getTraceCtx(): IDistributedTraceContext | null | undefined { + public getTraceCtx(): IDistributedTraceContext | null { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging return null; } + /** + * Sets the current distributed trace context for this instance if available + */ + public setTraceCtx(newTraceCtx: IDistributedTraceContext | null | undefined): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + /** * Watches and tracks changes for accesses to the current config, and if the accessed config changes the * handler will be recalled. @@ -945,6 +1044,72 @@ export class AppInsightsSku implements IApplicationInsights { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging return null; } + + /** + * Start a new span with the given name and optional parent context. + * + * Note: This method only creates and returns the span. It does not automatically + * set the span as the active trace context. Context management should be handled + * separately using setTraceCtx() if needed. + * + * @param name - The name of the span + * @param options - Options for creating the span (kind, attributes, startTime) + * @param parent - Optional parent context. If not provided, uses the current active trace context + * @returns A new span instance, or null if no trace provider is available + * @since 3.4.0 + */ + public startSpan(name: string, options?: IOTelSpanOptions, parent?: IDistributedTraceContext): IReadableSpan | null { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + /** + * Return the current active span, if no trace provider is available null will be returned + * but when a trace provider is available a span instance will always be returned, even if + * there is no active span (in which case a non-recording span will be returned). + * @param createNew - Optional flag to create a non-recording span if no active span exists, defaults to true. + * When false, returns the existing active span or null without creating a non-recording span. + * @returns The current active span or null if no trace provider is available or if createNew is false and no active span exists + * @since 3.4.0 + */ + public getActiveSpan(createNew?: boolean): IReadableSpan | null { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + /** + * Set the current Active Span, if no trace provider is available the span will be not be set as the active span. + * @param span - The span to set as the active span + * @returns An ISpanScope instance that provides the current scope, the span will always be the span passed in + * even when no trace provider is available + * @since 3.4.0 + */ + public setActiveSpan(span: IReadableSpan): ISpanScope { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + /** + * Set the trace provider for creating spans. + * This allows different SKUs to provide their own span implementations. + * + * @param provider - The trace provider to use for span creation + * @since 3.4.0 + */ + public setTraceProvider(provider: ICachedValue): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + /** + * Get the current trace provider. + * + * @returns The current trace provider, or null if none is set + * @since 3.4.0 + */ + public getTraceProvider(): ITraceProvider | null { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } } // tslint:disable-next-line diff --git a/AISKU/src/IApplicationInsights.ts b/AISKU/src/IApplicationInsights.ts index 254d012b0..1979fdbe5 100644 --- a/AISKU/src/IApplicationInsights.ts +++ b/AISKU/src/IApplicationInsights.ts @@ -1,21 +1,33 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -"use strict"; - import { AnalyticsPlugin } from "@microsoft/applicationinsights-analytics-js"; import { Sender } from "@microsoft/applicationinsights-channel-js"; import { IAppInsights, IPropertiesPlugin, IRequestHeaders } from "@microsoft/applicationinsights-common"; import { - IConfiguration, ILoadedPlugin, IPlugin, ITelemetryPlugin, ITelemetryUnloadState, UnloadHandler + IConfiguration, ILoadedPlugin, IOTelApi, IPlugin, ITelemetryPlugin, ITelemetryUnloadState, ITraceApi, ITraceHost, UnloadHandler } from "@microsoft/applicationinsights-core-js"; import { IDependenciesPlugin } from "@microsoft/applicationinsights-dependencies-js"; import { IPromise } from "@nevware21/ts-async"; export { IRequestHeaders }; -export interface IApplicationInsights extends IAppInsights, IDependenciesPlugin, IPropertiesPlugin { +export interface IApplicationInsights extends IAppInsights, IDependenciesPlugin, IPropertiesPlugin, ITraceHost { appInsights: AnalyticsPlugin; + /** + * The OpenTelemetry API instance associated with this instance + * Unlike OpenTelemetry, this API does not return a No-Op implementation and returns null if the SDK has been torn + * down or not yet initialized. + */ + readonly otelApi: IOTelApi | null; + + /** + * OpenTelemetry trace API for creating spans. + * Unlike OpenTelemetry, this API does not return a No-Op implementation and returns null if the SDK has been torn + * down or not yet initialized. + */ + readonly trace: ITraceApi | null; + /** * Attempt to flush data immediately; If executing asynchronously (the default) and * you DO NOT pass a callback function then a [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) @@ -59,7 +71,7 @@ export interface IApplicationInsights extends IAppInsights, IDependenciesPlugin, /** * Find and return the (first) plugin with the specified identifier if present - * @param pluginIdentifier + * @param pluginIdentifier - The identifier of the plugin to find */ getPlugin(pluginIdentifier: string): ILoadedPlugin; diff --git a/AISKU/src/Init.ts b/AISKU/src/Init.ts index 22c39f5ab..997c66408 100644 --- a/AISKU/src/Init.ts +++ b/AISKU/src/Init.ts @@ -82,6 +82,7 @@ export { ITraceTelemetry, IMetricTelemetry, IEventTelemetry, + IRequestTelemetry, IAppInsights, eSeverityLevel, IRequestHeaders, diff --git a/AISKU/src/InternalConstants.ts b/AISKU/src/InternalConstants.ts index 9e840b4d9..94c935053 100644 --- a/AISKU/src/InternalConstants.ts +++ b/AISKU/src/InternalConstants.ts @@ -10,6 +10,8 @@ const _AUTHENTICATED_USER_CONTEXT = "AuthenticatedUserContext"; const _TRACK = "track"; + +export const UNDEFINED_VALUE: undefined = undefined; export const STR_EMPTY = ""; export const STR_SNIPPET = "snippet"; export const STR_GET_COOKIE_MGR = "getCookieMgr"; diff --git a/AISKU/src/applicationinsights-web.ts b/AISKU/src/applicationinsights-web.ts index 0ad8ca2b3..09d0af761 100644 --- a/AISKU/src/applicationinsights-web.ts +++ b/AISKU/src/applicationinsights-web.ts @@ -4,6 +4,9 @@ export { AppInsightsSku as ApplicationInsights } from "./AISku"; export { ApplicationInsightsContainer } from "./ApplicationInsightsContainer"; +// OpenTelemetry trace API exports (public interfaces only) +export { IOTelTracerProvider, IOTelTracer, IAttributeContainer, IOTelAttributes, IReadableSpan } from "@microsoft/applicationinsights-core-js"; + // Re-exports export { IConfiguration, @@ -30,7 +33,11 @@ export { INotificationManager, IProcessTelemetryContext, Tags, - ILoadedPlugin + ILoadedPlugin, + IOTelSpan, + eOTelSpanKind, + OTelSpanKind, + IOTelSpanOptions } from "@microsoft/applicationinsights-core-js"; export { IConfig, @@ -50,7 +57,6 @@ export { Metric, PageView, PageViewPerformance, - RemoteDependencyData, Trace, DistributedTracingModes, IRequestHeaders, diff --git a/AISKU/src/internal/trace/spanUtils.ts b/AISKU/src/internal/trace/spanUtils.ts new file mode 100644 index 000000000..d8854d1dd --- /dev/null +++ b/AISKU/src/internal/trace/spanUtils.ts @@ -0,0 +1,513 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CtxTagKeys, IContextTagKeys, IDependencyTelemetry, IRequestTelemetry, RemoteDependencyDataType, RemoteDependencyEnvelopeType, + RequestDataType, RequestEnvelopeType, createTelemetryItem, urlGetPathName +} from "@microsoft/applicationinsights-common"; +import { + ATTR_CLIENT_ADDRESS, ATTR_CLIENT_PORT, ATTR_ENDUSER_ID, ATTR_ENDUSER_PSEUDO_ID, ATTR_ERROR_TYPE, ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, ATTR_EXCEPTION_TYPE, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_HTTP_ROUTE, + ATTR_NETWORK_LOCAL_ADDRESS, ATTR_NETWORK_LOCAL_PORT, ATTR_NETWORK_PEER_ADDRESS, ATTR_NETWORK_PEER_PORT, ATTR_NETWORK_PROTOCOL_NAME, + ATTR_NETWORK_PROTOCOL_VERSION, ATTR_NETWORK_TRANSPORT, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, ATTR_URL_PATH, + ATTR_URL_QUERY, ATTR_URL_SCHEME, ATTR_USER_AGENT_ORIGINAL, DBSYSTEMVALUES_MONGODB, DBSYSTEMVALUES_MYSQL, DBSYSTEMVALUES_POSTGRESQL, + DBSYSTEMVALUES_REDIS, EXP_ATTR_ENDUSER_ID, EXP_ATTR_ENDUSER_PSEUDO_ID, EXP_ATTR_SYNTHETIC_TYPE, IAppInsightsCore, IAttributeContainer, + IConfiguration, IReadableSpan, ITelemetryItem, MicrosoftClientIp, OTelAttributeValue, SEMATTRS_DB_NAME, SEMATTRS_DB_OPERATION, + SEMATTRS_DB_STATEMENT, SEMATTRS_DB_SYSTEM, SEMATTRS_ENDUSER_ID, SEMATTRS_EXCEPTION_MESSAGE, SEMATTRS_EXCEPTION_STACKTRACE, + SEMATTRS_EXCEPTION_TYPE, SEMATTRS_HTTP_CLIENT_IP, SEMATTRS_HTTP_FLAVOR, SEMATTRS_HTTP_HOST, SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_ROUTE, + SEMATTRS_HTTP_SCHEME, SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_HTTP_TARGET, SEMATTRS_HTTP_URL, SEMATTRS_HTTP_USER_AGENT, SEMATTRS_NET_HOST_IP, + SEMATTRS_NET_HOST_NAME, SEMATTRS_NET_HOST_PORT, SEMATTRS_NET_PEER_IP, SEMATTRS_NET_PEER_NAME, SEMATTRS_NET_PEER_PORT, + SEMATTRS_NET_TRANSPORT, SEMATTRS_PEER_SERVICE, SEMATTRS_RPC_GRPC_STATUS_CODE, SEMATTRS_RPC_SYSTEM, Tags, createAttributeContainer, + eDependencyTypes, eOTelSpanKind, eOTelSpanStatusCode, fieldRedaction, getDependencyTarget, getHttpMethod, getHttpStatusCode, getHttpUrl, + getLocationIp, getUrl, getUserAgent, hrTimeToMilliseconds, isSqlDB, isSyntheticSource +} from "@microsoft/applicationinsights-core-js"; +import { + ILazyValue, arrIncludes, asString, getLazy, isNullOrUndefined, strLower, strStartsWith, strSubstring +} from "@nevware21/ts-utils"; +import { STR_EMPTY, UNDEFINED_VALUE } from "../../InternalConstants"; + +/** + * Azure SDK namespace. + * @internal + */ +const AzNamespace = "az.namespace"; + +/** + * Azure SDK Eventhub. + * @internal + */ +const MicrosoftEventHub = "Microsoft.EventHub"; + +/** + * Azure SDK message bus destination. + * @internal + */ +const MessageBusDestination = "message_bus.destination"; + +/** + * AI time since enqueued attribute. + * @internal + */ +const TIME_SINCE_ENQUEUED = "timeSinceEnqueued"; + +const PORT_REGEX: ILazyValue = (/*#__PURE__*/ getLazy(() => new RegExp(/(https?)(:\/\/.*)(:\d+)(\S*)/))); +const HTTP_DOT = (/*#__PURE__*/ "http."); + +const _MS_PROCESSED_BY_METRICS_EXTRACTORS = (/* #__PURE__*/"_MS.ProcessedByMetricExtractors"); +const enum eMaxPropertyLengths { + NINE_BIT = 512, + TEN_BIT = 1024, + THIRTEEN_BIT = 8192, + FIFTEEN_BIT = 32768, +} + +/** + * Legacy HTTP semantic convention values + * @internal + */ +const _ignoreSemanticValues: ILazyValue = (/* #__PURE__*/ getLazy(_initIgnoreSemanticValues)); + +function _initIgnoreSemanticValues(): string[] { + return [ + // Internal Microsoft attributes + _MS_PROCESSED_BY_METRICS_EXTRACTORS, + + // Legacy HTTP semantic values + SEMATTRS_NET_PEER_IP, + SEMATTRS_NET_PEER_NAME, + SEMATTRS_NET_HOST_IP, + SEMATTRS_PEER_SERVICE, + SEMATTRS_HTTP_USER_AGENT, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_URL, + SEMATTRS_HTTP_STATUS_CODE, + SEMATTRS_HTTP_ROUTE, + SEMATTRS_HTTP_HOST, + SEMATTRS_DB_SYSTEM, + SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_OPERATION, + SEMATTRS_DB_NAME, + SEMATTRS_RPC_SYSTEM, + SEMATTRS_RPC_GRPC_STATUS_CODE, + SEMATTRS_EXCEPTION_TYPE, + SEMATTRS_EXCEPTION_MESSAGE, + SEMATTRS_EXCEPTION_STACKTRACE, + SEMATTRS_HTTP_SCHEME, + SEMATTRS_HTTP_TARGET, + SEMATTRS_HTTP_FLAVOR, + SEMATTRS_NET_TRANSPORT, + SEMATTRS_NET_HOST_NAME, + SEMATTRS_NET_HOST_PORT, + SEMATTRS_NET_PEER_PORT, + SEMATTRS_HTTP_CLIENT_IP, + SEMATTRS_ENDUSER_ID, + HTTP_DOT + "status_text", + + // http Semabtic conventions + ATTR_CLIENT_ADDRESS, + ATTR_CLIENT_PORT, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_FULL, + ATTR_URL_PATH, + ATTR_URL_QUERY, + ATTR_URL_SCHEME, + ATTR_ERROR_TYPE, + ATTR_NETWORK_LOCAL_ADDRESS, + ATTR_NETWORK_LOCAL_PORT, + ATTR_NETWORK_PROTOCOL_NAME, + ATTR_NETWORK_PEER_ADDRESS, + ATTR_NETWORK_PEER_PORT, + ATTR_NETWORK_PROTOCOL_VERSION, + ATTR_NETWORK_TRANSPORT, + ATTR_USER_AGENT_ORIGINAL, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_EXCEPTION_TYPE, + ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, + EXP_ATTR_ENDUSER_ID, + EXP_ATTR_ENDUSER_PSEUDO_ID, + EXP_ATTR_SYNTHETIC_TYPE + ]; +} + +function _populateTagsFromSpan(telemetryItem: ITelemetryItem, span: IReadableSpan, contextKeys: IContextTagKeys, config: IConfiguration): void { + + let tags: Tags = telemetryItem.tags = (telemetryItem.tags || [] as Tags); + let container = span.attribContainer || createAttributeContainer(span.attributes); + + tags[contextKeys.operationId] = span.spanContext().traceId; + if (span.parentSpanContext?.spanId) { + tags[contextKeys.operationParentId] = span.parentSpanContext.spanId; + } + + // Map OpenTelemetry enduser attributes to Application Insights user attributes + const endUserId = container.get(ATTR_ENDUSER_ID); + if (endUserId) { + tags[contextKeys.userAuthUserId] = asString(endUserId); + } + + const endUserPseudoId = container.get(ATTR_ENDUSER_PSEUDO_ID); + if (endUserPseudoId) { + tags[contextKeys.userId] = asString(endUserPseudoId); + } + + const httpUserAgent = getUserAgent(container); + if (httpUserAgent) { + // TODO: Not exposed in Swagger, need to update def + tags["ai.user.userAgent"] = String(httpUserAgent); + } + if (isSyntheticSource(container)) { + tags[contextKeys.operationSyntheticSource] = "True"; + } + + // Check for microsoft.client.ip first - this takes precedence over all other IP logic + const microsoftClientIp = container.get(MicrosoftClientIp); + if (microsoftClientIp) { + tags[contextKeys.locationIp] = asString(microsoftClientIp); + } + + if (span.kind === eOTelSpanKind.SERVER) { + const httpMethod = getHttpMethod(container); + // Only use the fallback IP logic for server spans if microsoft.client.ip is not set + if (!microsoftClientIp) { + tags[contextKeys.locationIp] = getLocationIp(container); + } + + if (httpMethod) { + const httpRoute = container.get(ATTR_HTTP_ROUTE); + const httpUrl = getHttpUrl(container); + tags[contextKeys.operationName] = span.name; // Default + if (httpRoute) { + // AiOperationName max length is 1024 + // https://github.com/MohanGsk/ApplicationInsights-Home/blob/master/EndpointSpecs/Schemas/Bond/ContextTagKeys.bond + tags[contextKeys.operationName] = strSubstring(httpMethod + " " + fieldRedaction(asString(httpRoute), config), 0, eMaxPropertyLengths.TEN_BIT); + } else if (httpUrl) { + try { + const urlPathName = fieldRedaction(urlGetPathName(asString(httpUrl)), config); + tags[contextKeys.operationName] = strSubstring(httpMethod + " " + urlPathName, 0, eMaxPropertyLengths.TEN_BIT); + } catch { + /* no-op */ + } + } + } else { + tags[contextKeys.operationName] = span.name; + } + } else { + let opName = container.get(contextKeys.operationName); + if (opName) { + tags[contextKeys.operationName] = opName as string; + } + } + // TODO: Location IP TBD for non server spans +} + +/** + * Check to see if the key is in the list of known properties to ignore (exclude) + * from the properties collection + * @param key - the property key to check + * @param contextKeys - The current context keys + * @returns true if the key should be ignored, false otherwise + */ +function _isIgnorePropertiesKey(key: string, contextKeys: IContextTagKeys): boolean { + let result = false; + + if (arrIncludes(_ignoreSemanticValues.v, key)) { + // The key is in set of known keys to ignore + result = true; + } else if (strStartsWith(key, "microsoft.")) { + // Ignoring all ALL keys starting with "microsoft." + result = true; + } else if (key === contextKeys.operationName) { + // Ignoring the key if it is the operation name context tag + result = true; + } + + return result; +} + +function _populatePropertiesFromAttributes(item: ITelemetryItem, contextKeys: IContextTagKeys, container: IAttributeContainer): void { + if (container) { + let baseData = item.baseData = (item.baseData || {}); + let properties: { [propertyName: string]: any } = baseData.properties = (baseData.properties || {}); + + container.forEach((key: string, value) => { + // Avoid duplication ignoring fields already mapped. + if (!_isIgnorePropertiesKey(key, contextKeys)) { + properties[key] = value; + } + }); + } +} + +function _populateHttpDependencyProperties(dependencyTelemetry: IDependencyTelemetry, container: IAttributeContainer, httpMethod: OTelAttributeValue | undefined, config: IConfiguration): boolean { + if (httpMethod) { + // HTTP Dependency + const httpUrl = getHttpUrl(container); + if (httpUrl) { + try { + const dependencyUrl = new URL(String(httpUrl)); + dependencyTelemetry.name = httpMethod + " " + fieldRedaction(dependencyUrl.pathname, config); + } catch { + /* no-op */ + } + } + + dependencyTelemetry.type = eDependencyTypes.Http; + dependencyTelemetry.data = fieldRedaction(getUrl(container), config); + const httpStatusCode = getHttpStatusCode(container); + if (httpStatusCode) { + dependencyTelemetry.responseCode = +httpStatusCode; + } + + let target = getDependencyTarget(container); + if (target) { + try { + // Remove default port + const res = PORT_REGEX.v.exec(target); + if (res !== null) { + const protocol = res[1]; + const port = res[3]; + if ( + (protocol === "https" && port === ":443") || + (protocol === "http" && port === ":80") + ) { + // Drop port + target = res[1] + res[2] + res[4]; + } + } + } catch { + /* no-op */ + } + + dependencyTelemetry.target = target; + } + } + + return !!httpMethod; +} + +function _populateDbDependencyProperties(dependencyTelemetry: IDependencyTelemetry, container: IAttributeContainer, dbSystem: OTelAttributeValue | undefined): boolean { + if (dbSystem) { + // TODO: Remove special logic when Azure UX supports OpenTelemetry dbSystem + if (String(dbSystem) === DBSYSTEMVALUES_MYSQL) { + dependencyTelemetry.type = "mysql"; + } else if (String(dbSystem) === DBSYSTEMVALUES_POSTGRESQL) { + dependencyTelemetry.type = "postgresql"; + } else if (String(dbSystem) === DBSYSTEMVALUES_MONGODB) { + dependencyTelemetry.type = "mongodb"; + } else if (String(dbSystem) === DBSYSTEMVALUES_REDIS) { + dependencyTelemetry.type = "redis"; + } else if (isSqlDB(String(dbSystem))) { + dependencyTelemetry.type = "SQL"; + } else { + dependencyTelemetry.type = String(dbSystem); + } + const dbStatement = container.get(SEMATTRS_DB_STATEMENT); + const dbOperation = container.get(SEMATTRS_DB_OPERATION); + if (dbStatement) { + dependencyTelemetry.data = String(dbStatement); + } else if (dbOperation) { + dependencyTelemetry.data = String(dbOperation); + } + const target = getDependencyTarget(container); + const dbName = container.get(SEMATTRS_DB_NAME); + if (target) { + dependencyTelemetry.target = dbName ? `${target}|${dbName}` : `${target}`; + } else { + dependencyTelemetry.target = dbName ? `${dbName}` : `${dbSystem}`; + } + } + + return !!dbSystem; +} + +function _populateRpcDependencyProperties(dependencyTelemetry: IDependencyTelemetry, container: IAttributeContainer, rpcSystem: OTelAttributeValue | undefined): boolean { + if (rpcSystem) { + if (strLower(rpcSystem) === "wcf") { + dependencyTelemetry.type = eDependencyTypes.Wcf; + } else { + dependencyTelemetry.type = eDependencyTypes.Grpc; + } + const grpcStatusCode = container.get(SEMATTRS_RPC_GRPC_STATUS_CODE); + if (grpcStatusCode) { + dependencyTelemetry.responseCode = +grpcStatusCode; + } + const target = getDependencyTarget(container); + if (target) { + dependencyTelemetry.target = `${target}`; + } else { + dependencyTelemetry.target = String(rpcSystem); + } + } + + return !!rpcSystem; +} + +function createDependencyTelemetryItem(core: IAppInsightsCore, span: IReadableSpan, contextKeys: IContextTagKeys): ITelemetryItem { + let container = span.attribContainer || createAttributeContainer(span.attributes); + let dependencyType = "Dependency"; + + if (span.kind === eOTelSpanKind.PRODUCER) { + dependencyType = eDependencyTypes.QueueMessage; + } else if (span.kind === eOTelSpanKind.INTERNAL && span.parentSpanContext) { + dependencyType = eDependencyTypes.InProc; + } + + let spanCtx = span.spanContext(); + let dependencyTelemetry: IDependencyTelemetry = { + name: span.name, // Default + id: spanCtx.spanId || core.getTraceCtx().spanId, + success: span.status?.code !== eOTelSpanStatusCode.ERROR, + responseCode: 0, + type: dependencyType, + duration: hrTimeToMilliseconds(span.duration), + data: STR_EMPTY, + target: STR_EMPTY, + properties: UNDEFINED_VALUE, + measurements: UNDEFINED_VALUE + }; + + // Check for HTTP Dependency + if (!_populateHttpDependencyProperties(dependencyTelemetry, container, getHttpMethod(container), core.config)) { + // Check for DB Dependency + if (!_populateDbDependencyProperties(dependencyTelemetry, container, container.get(SEMATTRS_DB_SYSTEM))) { + // Check for Rpc Dependency + _populateRpcDependencyProperties(dependencyTelemetry, container, container.get(SEMATTRS_RPC_SYSTEM)); + } + } + + return createTelemetryItem(dependencyTelemetry, RemoteDependencyDataType, RemoteDependencyEnvelopeType.replace("{0}.", ""), core.logger); +} + +function createRequestTelemetryItem(core: IAppInsightsCore, span: IReadableSpan, contextKeys: IContextTagKeys): ITelemetryItem { + let container = span.attribContainer || createAttributeContainer(span.attributes); + + let spanCtx = span.spanContext(); + const requestData: IRequestTelemetry = { + name: span.name, // Default + id: spanCtx.spanId || core.getTraceCtx().spanId, + success: + span.status.code !== eOTelSpanStatusCode.UNSET + ? span.status.code === eOTelSpanStatusCode.OK + : (Number(getHttpStatusCode(container)) || 0) < 400, + responseCode: 0, + duration: hrTimeToMilliseconds(span.duration), + source: undefined + }; + const httpMethod = getHttpMethod(container); + const grpcStatusCode = container.get(SEMATTRS_RPC_GRPC_STATUS_CODE); + if (httpMethod) { + requestData.url = fieldRedaction(getUrl(container), core.config); + const httpStatusCode = getHttpStatusCode(container); + if (httpStatusCode) { + requestData.responseCode = +httpStatusCode; + } + } else if (grpcStatusCode) { + requestData.responseCode = +grpcStatusCode; + } + + return createTelemetryItem(requestData, RequestDataType, RequestEnvelopeType.replace("{0}.", ""), core.logger); +} + +export function createTelemetryItemFromSpan(core: IAppInsightsCore, span: IReadableSpan): ITelemetryItem | null { + let telemetryItem: ITelemetryItem = null; + let container = span.attribContainer || createAttributeContainer(span.attributes); + let contextKeys: IContextTagKeys = CtxTagKeys; + let kind = span.kind; + if (kind == eOTelSpanKind.SERVER || kind == eOTelSpanKind.CONSUMER) { + // Request + telemetryItem = createRequestTelemetryItem(core, span, contextKeys); + } else if (kind == eOTelSpanKind.CLIENT || kind == eOTelSpanKind.PRODUCER || kind == eOTelSpanKind.INTERNAL) { + // RemoteDependency + telemetryItem = createDependencyTelemetryItem(core, span, contextKeys); + } else { + //diag.error(`Unsupported span kind ${span.kind}`); + } + + if (telemetryItem) { + // Set start time for the telemetry item from the event, not the time it is being processed (the default) + // The channel envelope creator uses this value when creating the envelope only when defined, otherwise it + // uses the time when the item is being processed + let baseData = telemetryItem.baseData = telemetryItem.baseData || {}; + baseData.startTime = new Date(hrTimeToMilliseconds(span.startTime)); + + // Add dt extension to the telemetry item + let ext = telemetryItem.ext = telemetryItem.ext || {}; + let dt = ext["dt"] = ext["dt"] || {}; + + // Don't overwrite any existing values + dt.spanId = dt.spanId || span.spanContext().spanId; + dt.traceId = dt.traceId || span.spanContext().traceId; + + let traceFlags = span.spanContext().traceFlags; + if (!isNullOrUndefined(traceFlags)) { + dt.traceFlags = dt.traceFlags || traceFlags; + } + + _populateTagsFromSpan(telemetryItem, span, contextKeys, core.config); + _populatePropertiesFromAttributes(telemetryItem, contextKeys, container); + + let sampleRate = container.get("microsoft.sample_rate"); + if (!isNullOrUndefined(sampleRate)) { + (telemetryItem as any).sampleRate = Number(sampleRate); + } + + // Azure SDK + let azNamespace = container.get(AzNamespace); + if (azNamespace) { + if (span.kind === eOTelSpanKind.INTERNAL) { + baseData.type = eDependencyTypes.InProc + " | " + azNamespace; + } + + if (azNamespace === MicrosoftEventHub) { + _parseEventHubSpan(telemetryItem, span); + } + } + } + + return telemetryItem; +} + +/** + * Implementation of Mapping to Azure Monitor + * + * https://gist.github.com/lmolkova/e4215c0f44a49ef824983382762e6b92#mapping-to-azure-monitor-application-insights-telemetry + * @internal + */ +function _parseEventHubSpan(telemetryItem: ITelemetryItem, span: IReadableSpan): void { + let baseData = telemetryItem.baseData = telemetryItem.baseData || {}; + let container = span.attribContainer || createAttributeContainer(span.attributes); + const namespace = container.get(AzNamespace); + const peerAddress = asString(container.get(SEMATTRS_NET_PEER_NAME) || container.get("peer.address") || "unknown").replace(/\/$/g, ""); // remove trailing "/" + const messageBusDestination = (container.get(MessageBusDestination) || "unknown") as string; + let baseType = baseData.type || ""; + let kind = span.kind; + + if (kind === eOTelSpanKind.CLIENT) { + baseType = namespace; + baseData.target = peerAddress + "/" + messageBusDestination; + } else if (kind === eOTelSpanKind.PRODUCER) { + baseType = "Queue Message | " + namespace; + baseData.target = peerAddress + "/" + messageBusDestination; + } else if (kind === eOTelSpanKind.CONSUMER) { + baseType = "Queue Message | " + namespace; + (baseData as any).source = peerAddress + "/" + messageBusDestination; + + let measurements = baseData.measurements = (baseData.measurements || {}); + let timeSinceEnqueued = container.get("timeSinceEnqueued"); + if (timeSinceEnqueued) { + measurements[TIME_SINCE_ENQUEUED] = Number(timeSinceEnqueued); + } else { + let enqueuedTime = parseFloat(asString(container.get("enqueuedTime"))); + if (isNaN(enqueuedTime)) { + enqueuedTime = 0; + } + + measurements[TIME_SINCE_ENQUEUED] = hrTimeToMilliseconds(span.startTime) - enqueuedTime; + } + } + + baseData.type = baseType; +} diff --git a/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts b/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts index 2706ed7cc..ec791f308 100644 --- a/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts +++ b/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts @@ -51,8 +51,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKULightSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 100; - private readonly MAX_BUNDLE_SIZE = 100; + private readonly MAX_RAW_SIZE = 102; + private readonly MAX_BUNDLE_SIZE = 102; private readonly MAX_RAW_DEFLATE_SIZE = 42; private readonly MAX_BUNDLE_DEFLATE_SIZE = 42; private readonly rawFilePath = "../dist/es5/applicationinsights-web-basic.min.js"; diff --git a/AISKULight/Tests/Unit/src/aiskuliteunittests.ts b/AISKULight/Tests/Unit/src/aiskuliteunittests.ts index f1a132879..65eb86968 100644 --- a/AISKULight/Tests/Unit/src/aiskuliteunittests.ts +++ b/AISKULight/Tests/Unit/src/aiskuliteunittests.ts @@ -2,10 +2,12 @@ import { AISKULightSizeCheck } from "./AISKULightSize.Tests"; import { ApplicationInsightsDynamicConfigTests } from "./dynamicconfig.tests"; import { ApplicationInsightsConfigTests } from "./config.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; +import { AISKULightOTelNegativeTests } from "./otelNegative.tests"; export function runTests() { new GlobalTestHooks().registerTests(); new AISKULightSizeCheck().registerTests(); new ApplicationInsightsDynamicConfigTests().registerTests(); new ApplicationInsightsConfigTests().registerTests(); + new AISKULightOTelNegativeTests().registerTests(); } \ No newline at end of file diff --git a/AISKULight/Tests/Unit/src/otelNegative.tests.ts b/AISKULight/Tests/Unit/src/otelNegative.tests.ts new file mode 100644 index 000000000..4290043d0 --- /dev/null +++ b/AISKULight/Tests/Unit/src/otelNegative.tests.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { ApplicationInsights } from "../../../src/index"; +import { utlRemoveSessionStorage } from "@microsoft/applicationinsights-common"; +import { isNullOrUndefined, newId } from "@microsoft/applicationinsights-core-js"; +import { isNull } from "util"; + +/** + * Negative tests for OpenTelemetry usage in AISKU Light + * These tests ensure that no exceptions are thrown and helpers behave correctly + * when there is no trace provider or OTel support instances + */ +export class AISKULightOTelNegativeTests extends AITestClass { + private readonly _instrumentationKey = "testIkey-1234-5678-9012-3456789012"; + private _sessionPrefix: string; + + public testInitialize() { + super.testInitialize(); + this._sessionPrefix = newId(); + } + + public testCleanup() { + utlRemoveSessionStorage(null as any, "AI_sentBuffer"); + utlRemoveSessionStorage(null as any, "AI_buffer"); + utlRemoveSessionStorage(null as any, this._sessionPrefix + "_AI_sentBuffer"); + utlRemoveSessionStorage(null as any, this._sessionPrefix + "_AI_buffer"); + super.testCleanup(); + } + + public registerTests() { + this.addTraceContextWithoutProviderTests(); + this.addUnloadWithoutProviderTests(); + this.addConfigurationChangesWithoutProviderTests(); + } + + private addTraceContextWithoutProviderTests(): void { + this.testCase({ + name: "AISKULight.getTraceCtx: should return valid context without trace provider", + test: () => { + // Arrange + const config = { + instrumentationKey: this._instrumentationKey, + namePrefix: this._sessionPrefix + }; + const ai = new ApplicationInsights(config); + this.onDone(() => { + ai.unload(false); + }); + + // Act - no trace provider is set by default in AISKU Light + const ctx = ai.getTraceCtx(); + + // Assert - should return a valid context without throwing + Assert.ok(ctx !== undefined, "Should return a context (can be null)"); + + // If it returns a context, it should be valid + Assert.ok(!isNullOrUndefined(ctx?.traceId), "Context should have traceId"); + Assert.ok(!isNullOrUndefined(ctx?.spanId), "Context should have spanId"); + Assert.equal("", ctx?.spanId, "SpanId should be empty string without provider"); + } + }); + + this.testCase({ + name: "AISKULight.getTraceCtx: should not throw when called multiple times", + test: () => { + // Arrange + const config = { + instrumentationKey: this._instrumentationKey, + namePrefix: this._sessionPrefix + }; + const ai = new ApplicationInsights(config); + this.onDone(() => { + ai.unload(false); + }); + + // Act & Assert + Assert.doesNotThrow(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ctx1 = ai.getTraceCtx(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ctx2 = ai.getTraceCtx(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ctx3 = ai.getTraceCtx(); + + // Multiple calls should work without issues + Assert.ok(true, "Multiple getTraceCtx calls should not throw"); + Assert.equal(_ctx1, _ctx2, "Multiple calls should return same context instance"); + Assert.equal(_ctx2, _ctx3, "Multiple calls should return same context instance"); + Assert.equal(_ctx1?.traceId, _ctx2?.traceId, "TraceId should be consistent across calls"); + Assert.equal(_ctx2?.traceId, _ctx3?.traceId, "TraceId should be consistent across calls"); + Assert.equal(_ctx1?.spanId, _ctx2?.spanId, "SpanId should be consistent across calls"); + Assert.equal(_ctx2?.spanId, _ctx3?.spanId, "SpanId should be consistent across calls"); + }, "Multiple getTraceCtx calls should be safe"); + } + }); + + this.testCase({ + name: "AISKULight: getTraceCtx should work after unload", + test: () => { + // Arrange + const config = { + instrumentationKey: this._instrumentationKey, + namePrefix: this._sessionPrefix + }; + const ai = new ApplicationInsights(config); + + // Act - unload first + ai.unload(false); + + // Assert - should not throw even after unload + Assert.doesNotThrow(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _ctx = ai.getTraceCtx(); + // Context might be null after unload, which is fine + }, "getTraceCtx should not throw after unload"); + } + }); + } + + + + private addUnloadWithoutProviderTests(): void { + this.testCase({ + name: "AISKULight: unload should work gracefully without trace provider", + test: () => { + // Arrange + const config = { + instrumentationKey: this._instrumentationKey, + namePrefix: this._sessionPrefix + }; + const ai = new ApplicationInsights(config); + + // Act & Assert + Assert.doesNotThrow(() => { + ai.unload(false); + }, "Unload should work without trace provider"); + + // Verify we can still access config after unload + Assert.ok(ai.config, "Config should still be accessible after unload"); + } + }); + + this.testCase({ + name: "AISKULight: unload with async flag should work without provider", + test: () => { + // Arrange + const config = { + instrumentationKey: this._instrumentationKey, + namePrefix: this._sessionPrefix + }; + const ai = new ApplicationInsights(config); + + // Act & Assert + Assert.doesNotThrow(() => { + ai.unload(true); + }, "Async unload should work without trace provider"); + } + }); + } + + private addConfigurationChangesWithoutProviderTests(): void { + this.testCase({ + name: "AISKULight: should handle traceCfg in config without trace provider", + test: () => { + // Arrange + const config = { + instrumentationKey: this._instrumentationKey, + namePrefix: this._sessionPrefix, + traceCfg: { + suppressTracing: false + } + }; + + // Act & Assert + Assert.doesNotThrow(() => { + const ai = new ApplicationInsights(config); + + // Verify traceCfg is present + Assert.ok(ai.config.traceCfg, "traceCfg should be accessible"); + + this.onDone(() => { + ai.unload(false); + }); + }, "Should handle traceCfg without trace provider"); + } + }); + } +} diff --git a/AISKULight/src/index.ts b/AISKULight/src/index.ts index fabb7bcbb..d74ad083f 100644 --- a/AISKULight/src/index.ts +++ b/AISKULight/src/index.ts @@ -142,6 +142,7 @@ export class ApplicationInsights { item.baseData = item.baseData || {}; item.baseType = item.baseType || "EventData"; } + core.track(item); } } @@ -236,7 +237,7 @@ export class ApplicationInsights { /** * Gets the current distributed trace context for this instance if available */ - public getTraceCtx(): IDistributedTraceContext | null | undefined { + public getTraceCtx(): IDistributedTraceContext | null { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging return null; } @@ -294,6 +295,7 @@ export { IEventTelemetry, IMetricTelemetry, IPageViewPerformanceTelemetry, - ITraceTelemetry + ITraceTelemetry, + IRequestTelemetry } from "@microsoft/applicationinsights-common"; export { Sender, ISenderConfig } from "@microsoft/applicationinsights-channel-js"; diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/Sample.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/Sample.tests.ts index 1c02abaf8..982033d0a 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/Sample.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/Sample.tests.ts @@ -1,11 +1,11 @@ import { AITestClass } from "@microsoft/ai-test-framework"; -import { Sample } from "../../../src/TelemetryProcessors/Sample"; +import { createSampler } from "../../../src/TelemetryProcessors/Sample"; import { ITelemetryItem, isBeaconsSupported, newId } from "@microsoft/applicationinsights-core-js"; -import { PageView, TelemetryItemCreator, IPageViewTelemetry } from "@microsoft/applicationinsights-common"; -import { HashCodeScoreGenerator } from "../../../src/TelemetryProcessors/SamplingScoreGenerators/HashCodeScoreGenerator"; +import { TelemetryItemCreator, IPageViewTelemetry, PageViewDataType, PageViewEnvelopeType, ISample } from "@microsoft/applicationinsights-common"; +import { getHashCodeScore } from "../../../src/TelemetryProcessors/SamplingScoreGenerators/HashCodeScoreGenerator"; export class SampleTests extends AITestClass { - private sample: Sample + private sample: ISample; private item: ITelemetryItem; public testInitialize() { @@ -26,9 +26,9 @@ export class SampleTests extends AITestClass { this.testCase({ name: 'Sampling: isSampledIn returns true for 100 sampling rate', test: () => { - this.sample = new Sample(100); + this.sample = createSampler(100); this.item = this.getTelemetryItem(); - const scoreStub = this.sandbox.stub(this.sample["samplingScoreGenerator"], "getSamplingScore"); + const scoreStub = this.sandbox.stub(this.sample["generator"], "getScore"); QUnit.assert.ok(this.sample.isSampledIn(this.item)); QUnit.assert.ok(scoreStub.notCalled); @@ -38,7 +38,7 @@ export class SampleTests extends AITestClass { this.testCase({ name: 'Sampling: hashing is based on user id even if operation id is provided', test: () => { - this.sample = new Sample(33); + this.sample = createSampler(33); const userid = "asdf"; @@ -60,7 +60,7 @@ export class SampleTests extends AITestClass { this.testCase({ name: 'Sampling: hashing is based on operation id if no user id is provided', test: () => { - this.sample = new Sample(33); + this.sample = createSampler(33); const operationId = "operation id"; const item1 = this.getTelemetryItem(); @@ -95,7 +95,7 @@ export class SampleTests extends AITestClass { name: 'Sampling: hashing is random if no user id nor operation id provided', test: () => { // setup - this.sample = new Sample(33); + this.sample = createSampler(33); const envelope1 = this.getTelemetryItem(); envelope1.tags["ai.user.id"] = null; @@ -127,11 +127,10 @@ export class SampleTests extends AITestClass { // act sampleRates.forEach((sampleRate) => { - const sut = new HashCodeScoreGenerator(); let countOfSampledItems = 0; ids.forEach((id) => { - if (sut.getHashCodeScore(id) < sampleRate) {++countOfSampledItems; } + if (getHashCodeScore(id) < sampleRate) {++countOfSampledItems; } }); // Assert @@ -147,7 +146,7 @@ export class SampleTests extends AITestClass { return TelemetryItemCreator.create({ name: 'some page', uri: 'some uri' - }, PageView.dataType, PageView.envelopeType, null); + }, PageViewDataType, PageViewEnvelopeType, null); } private getMetricItem(): ITelemetryItem { diff --git a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts index dfa7aef89..3a1ae2610 100644 --- a/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts +++ b/channels/applicationinsights-channel-js/Tests/Unit/src/Sender.tests.ts @@ -1,6 +1,6 @@ import { AITestClass, PollingAssert } from "@microsoft/ai-test-framework"; import { Sender } from "../../../src/Sender"; -import { IOfflineListener, createOfflineListener, utlGetSessionStorageKeys, utlRemoveSessionStorage } from "@microsoft/applicationinsights-common"; +import { ExceptionDataType, IOfflineListener, createOfflineListener, utlGetSessionStorageKeys, utlRemoveSessionStorage } from "@microsoft/applicationinsights-common"; import { EnvelopeCreator } from '../../../src/EnvelopeCreator'; import { Exception, CtxTagKeys, isBeaconApiSupported, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, utlCanUseSessionStorage, utlGetSessionStorage, utlSetSessionStorage } from "@microsoft/applicationinsights-common"; import { ITelemetryItem, AppInsightsCore, ITelemetryPlugin, DiagnosticLogger, NotificationManager, SendRequestReason, _eInternalMessageId, safeGetLogger, isString, isArray, arrForEach, isBeaconsSupported, IXHROverride, IPayloadData,TransportType, getWindow, ActiveStatus } from "@microsoft/applicationinsights-core-js"; @@ -3664,7 +3664,7 @@ export class SenderTests extends AITestClass { name: "test", time: new Date("2018-06-12").toISOString(), iKey: "iKey", - baseType: Exception.dataType, + baseType: ExceptionDataType, baseData: bd, data: { "property3": "val3", @@ -4325,7 +4325,7 @@ export class SenderTests extends AITestClass { name: "test", time: new Date("2018-06-12").toISOString(), iKey: "iKey", - baseType: Exception.dataType, + baseType: ExceptionDataType, baseData: bd, data: { "property3": "val3", diff --git a/channels/applicationinsights-channel-js/src/EnvelopeCreator.ts b/channels/applicationinsights-channel-js/src/EnvelopeCreator.ts index b843672f8..ab34aa8eb 100644 --- a/channels/applicationinsights-channel-js/src/EnvelopeCreator.ts +++ b/channels/applicationinsights-channel-js/src/EnvelopeCreator.ts @@ -1,13 +1,19 @@ import { - CtxTagKeys, Data, Envelope, Event, Exception, HttpMethod, IDependencyTelemetry, IEnvelope, IExceptionInternal, - IPageViewPerformanceTelemetry, IPageViewTelemetryInternal, IWeb, Metric, PageView, PageViewPerformance, RemoteDependencyData, SampleRate, - Trace, dataSanitizeString + AIData, CtxTagKeys, Envelope, Event, EventDataType, EventEnvelopeType, Exception, ExceptionDataType, ExceptionEnvelopeType, HttpMethod, + IDependencyTelemetry, IEnvelope, IExceptionInternal, IPageViewPerformanceTelemetry, IPageViewTelemetryInternal, IRemoteDependencyData, + IRequestTelemetry, ISerializable, IWeb, Metric, MetricDataType, MetricEnvelopeType, PageView, PageViewDataType, PageViewEnvelopeType, + PageViewPerformance, PageViewPerformanceDataType, PageViewPerformanceEnvelopeType, RemoteDependencyDataType, RequestDataType, SampleRate, + Trace, TraceDataType, TraceEnvelopeType, dataSanitizeString } from "@microsoft/applicationinsights-common"; import { - IDiagnosticLogger, ITelemetryItem, _eInternalMessageId, _throwInternal, _warnToConsole, eLoggingSeverity, getJSON, hasJSON, + IDiagnosticLogger, ITelemetryItem, Tags, _eInternalMessageId, _throwInternal, _warnToConsole, eLoggingSeverity, getJSON, hasJSON, isDate, isNullOrUndefined, isNumber, isString, isTruthy, objForEachKey, optimizeObject, setValue, toISOString } from "@microsoft/applicationinsights-core-js"; +import { IRequestData } from "./Interfaces/Contracts/IRequestData"; import { STR_DURATION } from "./InternalConstants"; +import { _createData } from "./Telemetry/Common/Data"; +import { RemoteDependencyEnvelopeType, createRemoteDependencyData } from "./Telemetry/RemoteDependencyData"; +import { createRequestData } from "./Telemetry/RequestData"; // these two constants are used to filter out properties not needed when trying to extract custom properties and measurements from the incoming payload const strBaseType = "baseType"; @@ -26,7 +32,7 @@ function _extractPartAExtensions(logger: IDiagnosticLogger, item: ITelemetryItem // todo: switch to keys from common in this method let envTags = env.tags = env.tags || {}; let itmExt = item.ext = item.ext || {}; - let itmTags = item.tags = item.tags || []; + let itmTags = item.tags = item.tags || {} as Tags; let extUser = itmExt.user; if (extUser) { @@ -95,7 +101,7 @@ function _extractPartAExtensions(logger: IDiagnosticLogger, item: ITelemetryItem // ] // } - const tgs = {}; + const tgs: Tags = {}; // deals with tags.push({object}) for(let i = itmTags.length - 1; i >= 0; i--){ const tg = itmTags[i]; @@ -143,15 +149,18 @@ function _convertPropsUndefinedToCustomDefinedValue(properties: { [key: string]: } // TODO: Do we want this to take logger as arg or use this._logger as nonstatic? -function _createEnvelope(logger: IDiagnosticLogger, envelopeType: string, telemetryItem: ITelemetryItem, data: Data): IEnvelope { +function _createEnvelope(logger: IDiagnosticLogger, envelopeType: string, telemetryItem: ITelemetryItem, data: AIData): IEnvelope { const envelope = new Envelope(logger, data, envelopeType); - _setValueIf(envelope, "sampleRate", telemetryItem[SampleRate]); - if ((telemetryItem[strBaseData] || {}).startTime) { + _setValueIf(envelope, "sampleRate", (telemetryItem as any)[SampleRate]); + + let startTime = (telemetryItem[strBaseData] || {}).startTime; + if (isDate(startTime)) { // Starting from Version 3.0.3, the time property will be assigned by the startTime value, // which records the loadEvent time for the pageView event. - envelope.time = toISOString(telemetryItem[strBaseData].startTime); + envelope.time = toISOString(startTime); } + envelope.iKey = telemetryItem.iKey; const iKeyNoDashes = telemetryItem.iKey.replace(/-/g, ""); envelope.name = envelope.name.replace("{0}", iKeyNoDashes); @@ -193,21 +202,37 @@ export function DependencyEnvelopeCreator(logger: IDiagnosticLogger, telemetryIt } const method = bd[strProperties] && bd[strProperties][HttpMethod] ? bd[strProperties][HttpMethod] : "GET"; - const remoteDepData = new RemoteDependencyData(logger, bd.id, bd.target, bd.name, bd.duration, bd.success, bd.responseCode, method, bd.type, bd.correlationContext, customProperties, customMeasurements); - const data = new Data(RemoteDependencyData.dataType, remoteDepData); - return _createEnvelope(logger, RemoteDependencyData.envelopeType, telemetryItem, data); + const remoteDepData = createRemoteDependencyData(logger, bd.id, bd.target, bd.name, bd.duration, bd.success, bd.responseCode, method, bd.type, bd.correlationContext, customProperties, customMeasurements); + const data = _createData(RemoteDependencyDataType, remoteDepData); + return _createEnvelope(logger, RemoteDependencyEnvelopeType, telemetryItem, data); } +export function RequestEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any): IEnvelope { + EnvelopeCreatorInit(logger, telemetryItem); + + const customMeasurements = telemetryItem[strBaseData].measurements || {}; + const customProperties = telemetryItem[strBaseData][strProperties] || {}; + _extractPropsAndMeasurements(telemetryItem.data, customProperties, customMeasurements); + if (!isNullOrUndefined(customUndefinedValue)) { + _convertPropsUndefinedToCustomDefinedValue(customProperties, customUndefinedValue); + } + const bd = telemetryItem[strBaseData] as IRequestTelemetry; + const requestData = createRequestData(logger, bd.id, bd.name, bd.duration, bd.success, bd.responseCode, bd.source, bd.url, customProperties, customMeasurements); + const data = _createData(RequestDataType, requestData); + return _createEnvelope(logger, RequestDataType, telemetryItem, data); +} + + export function EventEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any): IEnvelope { EnvelopeCreatorInit(logger, telemetryItem); - let customProperties = {}; + let customProperties = {} as { [key: string]: any }; let customMeasurements = {}; - if (telemetryItem[strBaseType] !== Event.dataType) { - customProperties["baseTypeSource"] = telemetryItem[strBaseType]; // save the passed in base type as a property + if (telemetryItem[strBaseType] !== EventDataType) { + (customProperties as any)["baseTypeSource"] = telemetryItem[strBaseType]; // save the passed in base type as a property } - if (telemetryItem[strBaseType] === Event.dataType) { // take collection + if (telemetryItem[strBaseType] === EventDataType) { // take collection customProperties = telemetryItem[strBaseData][strProperties] || {}; customMeasurements = telemetryItem[strBaseData].measurements || {}; } else { // if its not a known type, convert to custom event @@ -223,8 +248,8 @@ export function EventEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: I } const eventName = telemetryItem[strBaseData].name; const eventData = new Event(logger, eventName, customProperties, customMeasurements); - const data = new Data(Event.dataType, eventData); - return _createEnvelope(logger, Event.envelopeType, telemetryItem, data); + const data = _createData(EventDataType, eventData); + return _createEnvelope(logger, EventEnvelopeType, telemetryItem, data); } export function ExceptionEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any): IEnvelope { @@ -239,8 +264,8 @@ export function ExceptionEnvelopeCreator(logger: IDiagnosticLogger, telemetryIte } const bd = telemetryItem[strBaseData] as IExceptionInternal; const exData = Exception.CreateFromInterface(logger, bd, customProperties, customMeasurements); - const data = new Data(Exception.dataType, exData); - return _createEnvelope(logger, Exception.envelopeType, telemetryItem, data); + const data = _createData(ExceptionDataType, exData); + return _createEnvelope(logger, ExceptionEnvelopeType, telemetryItem, data); } export function MetricEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any): IEnvelope { @@ -254,8 +279,8 @@ export function MetricEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: _convertPropsUndefinedToCustomDefinedValue(props, customUndefinedValue); } const baseMetricData = new Metric(logger, baseData.name, baseData.average, baseData.sampleCount, baseData.min, baseData.max, baseData.stdDev, props, measurements); - const data = new Data(Metric.dataType, baseMetricData); - return _createEnvelope(logger, Metric.envelopeType, telemetryItem, data); + const data = _createData(MetricDataType, baseMetricData); + return _createEnvelope(logger, MetricEnvelopeType, telemetryItem, data); } export function PageViewEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any): IEnvelope { @@ -316,8 +341,8 @@ export function PageViewEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem _convertPropsUndefinedToCustomDefinedValue(properties, customUndefinedValue); } const pageViewData = new PageView(logger, name, url, duration, properties, measurements, id); - const data = new Data(PageView.dataType, pageViewData); - return _createEnvelope(logger, PageView.envelopeType, telemetryItem, data); + const data = _createData(PageViewDataType, pageViewData); + return _createEnvelope(logger, PageViewEnvelopeType, telemetryItem, data); } export function PageViewPerformanceEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any): IEnvelope { @@ -333,8 +358,8 @@ export function PageViewPerformanceEnvelopeCreator(logger: IDiagnosticLogger, te _convertPropsUndefinedToCustomDefinedValue(properties, customUndefinedValue); } const baseData = new PageViewPerformance(logger, name, url, undefined, properties, measurements, bd); - const data = new Data(PageViewPerformance.dataType, baseData); - return _createEnvelope(logger, PageViewPerformance.envelopeType, telemetryItem, data); + const data = _createData(PageViewPerformanceDataType, baseData); + return _createEnvelope(logger, PageViewPerformanceEnvelopeType, telemetryItem, data); } export function TraceEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any): IEnvelope { @@ -349,6 +374,6 @@ export function TraceEnvelopeCreator(logger: IDiagnosticLogger, telemetryItem: I _convertPropsUndefinedToCustomDefinedValue(props, customUndefinedValue); } const baseData = new Trace(logger, message, severityLevel, props, measurements); - const data = new Data(Trace.dataType, baseData); - return _createEnvelope(logger, Trace.envelopeType, telemetryItem, data); + const data = _createData(TraceDataType, baseData); + return _createEnvelope(logger, TraceEnvelopeType, telemetryItem, data); } diff --git a/channels/applicationinsights-channel-js/src/Interfaces/Contracts/IRequestData.ts b/channels/applicationinsights-channel-js/src/Interfaces/Contracts/IRequestData.ts new file mode 100644 index 000000000..8601cde9d --- /dev/null +++ b/channels/applicationinsights-channel-js/src/Interfaces/Contracts/IRequestData.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IDomain } from "@microsoft/applicationinsights-common/src/Interfaces/Contracts/IDomain"; + +/** + * This interface indentifies the serialized request data contract that is sent to Application Insights backend + */ +export interface IRequestData extends IDomain { + + /** + * Schema version + */ + ver: number; /* 2 */ + + /** + * Identifier of a request call instance. Used for correlation between request and other telemetry items. + */ + id: string; + + /** + * Name of the request. Represents code path taken to process request. Low cardinality value to allow better grouping of requests. For HTTP requests it represents the HTTP method and URL path template like 'GET /values/\{id\}'. + */ + name?: string; + + /** + * Request duration in format: DD.HH:MM:SS.MMMMMM. Must be less than 1000 days. + */ + duration: string; + + /** + * Indication of successful or unsuccessful call. + */ + success: boolean; + + /** + * Result of a request execution. HTTP status code for HTTP requests. + */ + responseCode?: string; + + /** + * Source of the request. Examples are the instrumentation key of the caller or the ip address of the caller. + */ + source?: string; + + /** + * Request URL with all query string parameters. + */ + url?: string; + + /** + * Collection of custom properties. + */ + properties?: { [propertyName: string]: string }; /* \{\} */ + + /** + * Collection of custom measurements. + */ + measurements?: { [propertyName: string]: number }; /* \{\} */ +} diff --git a/channels/applicationinsights-channel-js/src/Sender.ts b/channels/applicationinsights-channel-js/src/Sender.ts index 5722b8905..732505599 100644 --- a/channels/applicationinsights-channel-js/src/Sender.ts +++ b/channels/applicationinsights-channel-js/src/Sender.ts @@ -1,8 +1,9 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { - BreezeChannelIdentifier, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, Event, Exception, IConfig, IEnvelope, IOfflineListener, ISample, - IStorageBuffer, Metric, PageView, PageViewPerformance, ProcessLegacy, RemoteDependencyData, RequestHeaders, SampleRate, Trace, - createOfflineListener, eRequestHeaders, isInternalApplicationInsightsEndpoint, utlCanUseSessionStorage, utlSetStoragePrefix + BreezeChannelIdentifier, DEFAULT_BREEZE_ENDPOINT, DEFAULT_BREEZE_PATH, EventDataType, ExceptionDataType, IConfig, IEnvelope, + IOfflineListener, ISample, IStorageBuffer, MetricDataType, PageViewDataType, PageViewPerformanceDataType, ProcessLegacy, + RemoteDependencyDataType, RequestDataType, RequestHeaders, SampleRate, TraceDataType, createOfflineListener, eRequestHeaders, + isInternalApplicationInsightsEndpoint, utlCanUseSessionStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-common"; import { ActiveStatus, BaseTelemetryPlugin, IAppInsightsCore, IBackendResponse, IChannelControls, IConfigDefaults, IConfiguration, @@ -21,12 +22,12 @@ import { } from "@nevware21/ts-utils"; import { DependencyEnvelopeCreator, EnvelopeCreator, EventEnvelopeCreator, ExceptionEnvelopeCreator, MetricEnvelopeCreator, - PageViewEnvelopeCreator, PageViewPerformanceEnvelopeCreator, TraceEnvelopeCreator + PageViewEnvelopeCreator, PageViewPerformanceEnvelopeCreator, RequestEnvelopeCreator, TraceEnvelopeCreator } from "./EnvelopeCreator"; import { IInternalStorageItem, ISenderConfig } from "./Interfaces"; import { ArraySendBuffer, ISendBuffer, SessionStorageSendBuffer } from "./SendBuffer"; import { Serializer } from "./Serializer"; -import { Sample } from "./TelemetryProcessors/Sample"; +import { createSampler } from "./TelemetryProcessors/Sample"; const UNDEFINED_VALUE: undefined = undefined; const EMPTY_STR = ""; @@ -93,13 +94,14 @@ function _chkSampling(value: number) { type EnvelopeCreator = (logger: IDiagnosticLogger, telemetryItem: ITelemetryItem, customUndefinedValue?: any) => IEnvelope; const EnvelopeTypeCreator: { [key:string] : EnvelopeCreator } = { - [Event.dataType]: EventEnvelopeCreator, - [Trace.dataType]: TraceEnvelopeCreator, - [PageView.dataType]: PageViewEnvelopeCreator, - [PageViewPerformance.dataType]: PageViewPerformanceEnvelopeCreator, - [Exception.dataType]: ExceptionEnvelopeCreator, - [Metric.dataType]: MetricEnvelopeCreator, - [RemoteDependencyData.dataType]: DependencyEnvelopeCreator + [EventDataType]: EventEnvelopeCreator, + [TraceDataType]: TraceEnvelopeCreator, + [PageViewDataType]: PageViewEnvelopeCreator, + [PageViewPerformanceDataType]: PageViewPerformanceEnvelopeCreator, + [ExceptionDataType]: ExceptionEnvelopeCreator, + [MetricDataType]: MetricEnvelopeCreator, + [RemoteDependencyDataType]: DependencyEnvelopeCreator, + [RequestDataType]: RequestEnvelopeCreator }; export type SenderFunction = (payload: string[] | IInternalStorageItem[], isAsync: boolean) => void | IPromise; @@ -268,7 +270,6 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { if (_self.isInitialized()) { _throwInternal(_self.diagLog(), eLoggingSeverity.CRITICAL, _eInternalMessageId.SenderNotInitialized, "Sender is already initialized"); } - _base.initialize(config, core, extensions, pluginChain); let identifier = _self.identifier; @@ -408,7 +409,7 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { _fetchKeepAlive = !senderConfig.onunloadDisableFetch && isFetchSupported(true); _disableBeaconSplit = !!senderConfig.disableSendBeaconSplit; - _self._sample = new Sample(senderConfig.samplingPercentage, diagLog); + _self._sample = createSampler(senderConfig.samplingPercentage, diagLog); _instrumentationKey = senderConfig.instrumentationKey; if(!isPromiseLike(_instrumentationKey) && !_validateInstrumentationKey(_instrumentationKey, config)) { @@ -905,13 +906,16 @@ export class Sender extends BaseTelemetryPlugin implements IChannelControls { } // check if this item should be sampled in, else add sampleRate tag - if (!_isSampledIn(telemetryItem)) { - // Item is sampled out, do not send it - diagLogger && _throwInternal(diagLogger, eLoggingSeverity.WARNING, _eInternalMessageId.TelemetrySampledAndNotSent, - "Telemetry item was sampled out and not sent", { SampleRate: _self._sample.sampleRate }); - return false; - } else { - telemetryItem[SampleRate] = _self._sample.sampleRate; + let sampleRate = (telemetryItem as any).sampleRate; + if (isNullOrUndefined(sampleRate) || !isNumber(sampleRate) || sampleRate < 0 || sampleRate > 100) { + if (!_isSampledIn(telemetryItem)) { + // Item is sampled out, do not send it + diagLogger && _throwInternal(diagLogger, eLoggingSeverity.WARNING, _eInternalMessageId.TelemetrySampledAndNotSent, + "Telemetry item was sampled out and not sent", { SampleRate: _self._sample.sampleRate }); + return false; + } else { + telemetryItem[SampleRate] = _self._sample.sampleRate; + } } return true; } diff --git a/channels/applicationinsights-channel-js/src/Serializer.ts b/channels/applicationinsights-channel-js/src/Serializer.ts index 4c289c515..d9a1d1453 100644 --- a/channels/applicationinsights-channel-js/src/Serializer.ts +++ b/channels/applicationinsights-channel-js/src/Serializer.ts @@ -1,164 +1,170 @@ import dynamicProto from "@microsoft/dynamicproto-js" import { - IDiagnosticLogger, _eInternalMessageId, _throwInternal, eLoggingSeverity, getJSON, isArray, isFunction, isObject, objForEachKey + IDiagnosticLogger, _eInternalMessageId, _throwInternal, eLoggingSeverity, getJSON, isArray, isFunction, isNullOrUndefined, isObject, objForEachKey } from "@microsoft/applicationinsights-core-js"; import { FieldType, ISerializable } from "@microsoft/applicationinsights-common"; -export class Serializer { +const enum eSerializeType { + String = 1, + Number = 2 +} - constructor(logger: IDiagnosticLogger) { - dynamicProto(Serializer, this, (_self) => { - /** - * Serializes the current object to a JSON string. - */ - _self.serialize = (input: ISerializable): string => { - const output = _serializeObject(input, "root"); - try { - return getJSON().stringify(output); - } catch (e) { - // if serialization fails return an empty string - _throwInternal(logger, eLoggingSeverity.CRITICAL, _eInternalMessageId.CannotSerializeObject, (e && isFunction(e.toString)) ? e.toString() : "Error serializing object", null, true); - } +/** + * Used to "tag" objects that are currently being serialized to detect circular references + */ +const circularReferenceCheck = "__aiCircularRefCheck"; + +function _serializeObject(logger: IDiagnosticLogger, source: ISerializable, name: string): any { + let output: any = {}; + + if (!source) { + _throwInternal(logger, eLoggingSeverity.CRITICAL, _eInternalMessageId.CannotSerializeObject, "cannot serialize object because it is null or undefined", { name }, true); + return output; + } + + if ((source as any)[circularReferenceCheck]) { + _throwInternal(logger, eLoggingSeverity.WARNING, _eInternalMessageId.CircularReferenceDetected, "Circular reference detected while serializing object", { name }, true); + return output; + } + + if (!source.aiDataContract) { + // special case for measurements/properties/tags + if (name === "measurements") { + output = _serializeStringMap(logger, source, eSerializeType.Number, name); + } else if (name === "properties") { + output = _serializeStringMap(logger, source, eSerializeType.String, name); + } else if (name === "tags") { + output = _serializeStringMap(logger, source, eSerializeType.String, name); + } else if (isArray(source)) { + output = _serializeArray(logger, source as any, name); + } else { + _throwInternal(logger, eLoggingSeverity.WARNING, _eInternalMessageId.CannotSerializeObjectNonSerializable, "Attempting to serialize an object which does not implement ISerializable", { name }, true); + + try { + // verify that the object can be stringified + getJSON().stringify(source); + output = source; + } catch (e) { + // if serialization fails return an empty string + _throwInternal(logger, eLoggingSeverity.CRITICAL, _eInternalMessageId.CannotSerializeObject, (e && isFunction(e.toString)) ? e.toString() : "Error serializing object", null, true); } + } - function _serializeObject(source: ISerializable, name: string): any { - const circularReferenceCheck = "__aiCircularRefCheck"; - let output = {}; + return output; + } - if (!source) { - _throwInternal(logger, eLoggingSeverity.CRITICAL, _eInternalMessageId.CannotSerializeObject, "cannot serialize object because it is null or undefined", { name }, true); - return output; + (source as any)[circularReferenceCheck] = true; + objForEachKey(source.aiDataContract, (field, contract) => { + const fieldType = isFunction(contract) ? contract() : contract; + const isRequired = fieldType & FieldType.Required; + const isHidden = fieldType & FieldType.Hidden; + const isArray = fieldType & FieldType.Array; + const isPresent = (source as any)[field] !== undefined; + const isObj = isObject((source as any)[field]) && (source as any)[field] !== null; + + if (isRequired && !isPresent && !isArray) { + _throwInternal(logger, + eLoggingSeverity.CRITICAL, + _eInternalMessageId.MissingRequiredFieldSpecification, + "Missing required field specification. The field is required but not present on source", + { field, name }); + + // If not in debug mode, continue and hope the error is permissible + } else if (!isHidden) { // Don't serialize hidden fields + let value; + if (isObj) { + if (isArray) { + // special case; recurse on each object in the source array + value = _serializeArray(logger, (source as any)[field], field); + } else { + // recurse on the source object in this field + value = _serializeObject(logger, (source as any)[field], field); } + } else { + // assign the source field to the output even if undefined or required + value = (source as any)[field]; + } - if (source[circularReferenceCheck]) { - _throwInternal(logger, eLoggingSeverity.WARNING, _eInternalMessageId.CircularReferenceDetected, "Circular reference detected while serializing object", { name }, true); - return output; - } + // only emit this field if the value is defined + if (value !== undefined) { + output[field] = value; + } + } + }); - if (!source.aiDataContract) { - // special case for measurements/properties/tags - if (name === "measurements") { - output = _serializeStringMap(source, "number", name); - } else if (name === "properties") { - output = _serializeStringMap(source, "string", name); - } else if (name === "tags") { - output = _serializeStringMap(source, "string", name); - } else if (isArray(source)) { - output = _serializeArray(source as any, name); - } else { - _throwInternal(logger, eLoggingSeverity.WARNING, _eInternalMessageId.CannotSerializeObjectNonSerializable, "Attempting to serialize an object which does not implement ISerializable", { name }, true); - - try { - // verify that the object can be stringified - getJSON().stringify(source); - output = source; - } catch (e) { - // if serialization fails return an empty string - _throwInternal(logger, eLoggingSeverity.CRITICAL, _eInternalMessageId.CannotSerializeObject, (e && isFunction(e.toString)) ? e.toString() : "Error serializing object", null, true); - } - } - - return output; - } + delete (source as any)[circularReferenceCheck]; + return output; +} - source[circularReferenceCheck] = true; - objForEachKey(source.aiDataContract, (field, contract) => { - const isRequired = (isFunction(contract)) ? (contract() & FieldType.Required) : (contract & FieldType.Required); - const isHidden = (isFunction(contract)) ? (contract() & FieldType.Hidden) : (contract & FieldType.Hidden); - const isArray = contract & FieldType.Array; - - const isPresent = source[field] !== undefined; - const isObj = isObject(source[field]) && source[field] !== null; - - if (isRequired && !isPresent && !isArray) { - _throwInternal(logger, - eLoggingSeverity.CRITICAL, - _eInternalMessageId.MissingRequiredFieldSpecification, - "Missing required field specification. The field is required but not present on source", - { field, name }); - - // If not in debug mode, continue and hope the error is permissible - } else if (!isHidden) { // Don't serialize hidden fields - let value; - if (isObj) { - if (isArray) { - // special case; recurse on each object in the source array - value = _serializeArray(source[field], field); - } else { - // recurse on the source object in this field - value = _serializeObject(source[field], field); - } - } else { - // assign the source field to the output even if undefined or required - value = source[field]; - } - - // only emit this field if the value is defined - if (value !== undefined) { - output[field] = value; - } - } - }); - - delete source[circularReferenceCheck]; - return output; +function _serializeArray(logger: IDiagnosticLogger, sources: ISerializable[], name: string): any[] { + let output: any[]; + + if (!!sources) { + if (!isArray(sources)) { + _throwInternal(logger, + eLoggingSeverity.CRITICAL, + _eInternalMessageId.ItemNotInArray, + "This field was specified as an array in the contract but the item is not an array.\r\n", + { name }, true); + } else { + output = []; + for (let i = 0; i < sources.length; i++) { + const source = sources[i]; + const item = _serializeObject(logger, source, name + "[" + i + "]"); + output.push(item); } + } + } - function _serializeArray(sources: ISerializable[], name: string): any[] { - let output: any[]; - - if (!!sources) { - if (!isArray(sources)) { - _throwInternal(logger, - eLoggingSeverity.CRITICAL, - _eInternalMessageId.ItemNotInArray, - "This field was specified as an array in the contract but the item is not an array.\r\n", - { name }, true); - } else { - output = []; - for (let i = 0; i < sources.length; i++) { - const source = sources[i]; - const item = _serializeObject(source, name + "[" + i + "]"); - output.push(item); - } - } - } + return output; +} - return output; +function _serializeStringMap(logger: IDiagnosticLogger, map: any, expectedType: eSerializeType, name: string) { + let output: any; + if (map) { + output = {}; + objForEachKey(map, (field, value) => { + let serializedValue: string | number; + if (value === undefined) { + serializedValue = "undefined"; + } else if (value === null) { + serializedValue = "null"; } - function _serializeStringMap(map: any, expectedType: string, name: string) { - let output: any; - if (map) { - output = {}; - objForEachKey(map, (field, value) => { - if (expectedType === "string") { - if (value === undefined) { - output[field] = "undefined"; - } else if (value === null) { - output[field] = "null"; - } else if (!value.toString) { - output[field] = "invalid field: toString() is not defined."; - } else { - output[field] = value.toString(); - } - } else if (expectedType === "number") { - if (value === undefined) { - output[field] = "undefined"; - } else if (value === null) { - output[field] = "null"; - } else { - const num = parseFloat(value); - output[field] = num; - } - } else { - output[field] = "invalid field: " + name + " is of unknown type."; - _throwInternal(logger, eLoggingSeverity.CRITICAL, output[field], null, true); - } - }); + if (expectedType === eSerializeType.String && !serializedValue) { + if (!value.toString) { + serializedValue = "invalid field: toString() is not defined."; + } else { + serializedValue = value.toString(); } + } else if (expectedType === eSerializeType.Number && !serializedValue) { + serializedValue = parseFloat(value); + } + + if (serializedValue || !isNullOrUndefined(value)) { + output[field] = serializedValue; + } + }); + } + + return output; +} + +export class Serializer { - return output; + constructor(logger: IDiagnosticLogger) { + dynamicProto(Serializer, this, (_self) => { + /** + * Serializes the current object to a JSON string. + */ + _self.serialize = (input: ISerializable): string => { + const output = _serializeObject(logger, input, "root"); + try { + return getJSON().stringify(output); + } catch (e) { + // if serialization fails return an empty string + _throwInternal(logger, eLoggingSeverity.CRITICAL, _eInternalMessageId.CannotSerializeObject, (e && isFunction(e.toString)) ? e.toString() : "Error serializing object", null, true); + } } }); } diff --git a/channels/applicationinsights-channel-js/src/Telemetry/Common/Data.ts b/channels/applicationinsights-channel-js/src/Telemetry/Common/Data.ts new file mode 100644 index 000000000..c7bbabdc8 --- /dev/null +++ b/channels/applicationinsights-channel-js/src/Telemetry/Common/Data.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { AIData, FieldType, ISerializable } from "@microsoft/applicationinsights-common"; + +export function _createData(baseType: string, data: TDomain): AIData & ISerializable { + return { + baseType: baseType, + baseData: data, + aiDataContract: { + baseType: FieldType.Required, + baseData: FieldType.Required + } + }; +} diff --git a/channels/applicationinsights-channel-js/src/Telemetry/RemoteDependencyData.ts b/channels/applicationinsights-channel-js/src/Telemetry/RemoteDependencyData.ts new file mode 100644 index 000000000..dc39051bd --- /dev/null +++ b/channels/applicationinsights-channel-js/src/Telemetry/RemoteDependencyData.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + FieldType, IRemoteDependencyData, ISerializable, dataSanitizeMeasurements, dataSanitizeProperties, dataSanitizeString, dataSanitizeUrl, + msToTimeSpan, urlParseUrl +} from "@microsoft/applicationinsights-common"; +import { IDiagnosticLogger } from "@microsoft/applicationinsights-core-js"; + +export const RemoteDependencyEnvelopeType = "Microsoft.ApplicationInsights.{0}.RemoteDependency"; + +function AjaxHelperParseDependencyPath(logger: IDiagnosticLogger, absoluteUrl: string, method: string, commandName: string) { + let target, name = commandName, data = commandName; + + if (absoluteUrl && absoluteUrl.length > 0) { + const parsedUrl: HTMLAnchorElement = urlParseUrl(absoluteUrl); + target = parsedUrl.host; + if (!name) { + if (parsedUrl.pathname != null) { + let pathName: string = (parsedUrl.pathname.length === 0) ? "/" : parsedUrl.pathname; + if (pathName.charAt(0) !== "/") { + pathName = "/" + pathName; + } + data = parsedUrl.pathname; + name = dataSanitizeString(logger, method ? method + " " + pathName : pathName); + } else { + name = dataSanitizeString(logger, absoluteUrl); + } + } + } else { + target = commandName; + name = commandName; + } + + return { + target, + name, + data + }; +} + +export function createRemoteDependencyData( + logger: IDiagnosticLogger, id: string, absoluteUrl: string, commandName: string, value: number, success: boolean, + resultCode: number, method?: string, requestAPI: string = "Ajax", correlationContext?: string, properties?: Object, + measurements?: Object) : IRemoteDependencyData & ISerializable { + const dependencyFields = AjaxHelperParseDependencyPath(logger, absoluteUrl, method, commandName); + + let data: IRemoteDependencyData & ISerializable = { + ver: 2, + id: id, + duration: msToTimeSpan(value), + success: success, + resultCode: "" + resultCode, + type: dataSanitizeString(logger, requestAPI), + data: dataSanitizeUrl(logger, commandName) || dependencyFields.data, // get a value from hosturl if commandName not available + target: dataSanitizeString(logger, dependencyFields.target), + name: dataSanitizeString(logger, dependencyFields.name), + properties: dataSanitizeProperties(logger, properties), + measurements: dataSanitizeMeasurements(logger, measurements), + + aiDataContract: { + id: FieldType.Required, + ver: FieldType.Required, + name: FieldType.Default, + resultCode: FieldType.Default, + duration: FieldType.Default, + success: FieldType.Default, + data: FieldType.Default, + target: FieldType.Default, + type: FieldType.Default, + properties: FieldType.Default, + measurements: FieldType.Default, + + kind: FieldType.Default, + value: FieldType.Default, + count: FieldType.Default, + min: FieldType.Default, + max: FieldType.Default, + stdDev: FieldType.Default, + dependencyKind: FieldType.Default, + dependencySource: FieldType.Default, + commandName: FieldType.Default, + dependencyTypeName: FieldType.Default + } + }; + + if (correlationContext) { + data.target = "" + data.target + " | " + correlationContext; + } + + return data; +} diff --git a/channels/applicationinsights-channel-js/src/Telemetry/RequestData.ts b/channels/applicationinsights-channel-js/src/Telemetry/RequestData.ts new file mode 100644 index 000000000..8c6303972 --- /dev/null +++ b/channels/applicationinsights-channel-js/src/Telemetry/RequestData.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + FieldType, ISerializable, dataSanitizeMeasurements, dataSanitizeProperties, dataSanitizeString, dataSanitizeUrl, msToTimeSpan +} from "@microsoft/applicationinsights-common"; +import { IDiagnosticLogger, asString } from "@microsoft/applicationinsights-core-js"; +import { IRequestData } from "../Interfaces/Contracts/IRequestData"; + +export const RequestEnvelopeType = "Microsoft.ApplicationInsights.{0}.Request"; + +/** + * Constructs a new instance of the RequestData object + */ +export function createRequestData(logger: IDiagnosticLogger, id: string, name: string | undefined, value: number, success: boolean, responseCode: number, source?: string, url?: string, properties?: Object, measurements?: Object): IRequestData & ISerializable { + return { + ver: 2, + id: id, + name: dataSanitizeString(logger, name), + duration: msToTimeSpan(value), + success: success, + responseCode: asString(responseCode || "0"), + source: dataSanitizeString(logger, source), + url: dataSanitizeUrl(logger, url), + properties: dataSanitizeProperties(logger, properties), + measurements: dataSanitizeMeasurements(logger, measurements), + aiDataContract: { + id: FieldType.Required, + ver: FieldType.Required, + name: FieldType.Default, + responseCode: FieldType.Required, + duration: FieldType.Required, + success: FieldType.Required, + source: FieldType.Default, + url: FieldType.Default, + properties: FieldType.Default, + measurements: FieldType.Default + } + }; +} diff --git a/channels/applicationinsights-channel-js/src/TelemetryProcessors/Sample.ts b/channels/applicationinsights-channel-js/src/TelemetryProcessors/Sample.ts index a26737229..096fa73de 100644 --- a/channels/applicationinsights-channel-js/src/TelemetryProcessors/Sample.ts +++ b/channels/applicationinsights-channel-js/src/TelemetryProcessors/Sample.ts @@ -1,49 +1,47 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ISample, Metric } from "@microsoft/applicationinsights-common"; +import { ISample, MetricDataType } from "@microsoft/applicationinsights-common"; import { - IDiagnosticLogger, ITelemetryItem, _eInternalMessageId, eLoggingSeverity, safeGetLogger + IDiagnosticLogger, ITelemetryItem, _eInternalMessageId, _throwInternal, eLoggingSeverity } from "@microsoft/applicationinsights-core-js"; -import { SamplingScoreGenerator } from "./SamplingScoreGenerators/SamplingScoreGenerator"; +import { IScoreGenerator, createSamplingScoreGenerator } from "./SamplingScoreGenerators/SamplingScoreGenerator"; -export class Sample implements ISample { - public sampleRate: number; +function _isSampledIn(envelope: ITelemetryItem, samplingPercentage: number, scoreGenerator: IScoreGenerator): boolean { + let isSampledIn = false; - // We're using 32 bit math, hence max value is (2^31 - 1) - public INT_MAX_VALUE: number = 2147483647; - private samplingScoreGenerator: SamplingScoreGenerator; - - constructor(sampleRate: number, logger?: IDiagnosticLogger) { - let _logger = logger || safeGetLogger(null); - - if (sampleRate > 100 || sampleRate < 0) { - _logger.throwInternal(eLoggingSeverity.WARNING, - _eInternalMessageId.SampleRateOutOfRange, - "Sampling rate is out of range (0..100). Sampling will be disabled, you may be sending too much data which may affect your AI service level.", - { samplingRate: sampleRate }, true); - sampleRate = 100; - } + if (samplingPercentage === null || samplingPercentage === undefined || samplingPercentage >= 100) { + isSampledIn = true; + } else if (envelope.baseType === MetricDataType) { + // exclude MetricData telemetry from sampling + isSampledIn = true; + } - this.sampleRate = sampleRate; - this.samplingScoreGenerator = new SamplingScoreGenerator(); + if (!isSampledIn) { + isSampledIn = scoreGenerator.getScore(envelope) < samplingPercentage; } + + return isSampledIn; +} - /** - * Determines if an envelope is sampled in (i.e. will be sent) or not (i.e. will be dropped). - */ - public isSampledIn(envelope: ITelemetryItem): boolean { - const samplingPercentage = this.sampleRate; // 0 - 100 - let isSampledIn = false; +export function createSampler(sampleRate: number, logger?: IDiagnosticLogger): ISample { + let _samplingScoreGenerator = createSamplingScoreGenerator(); + + if (sampleRate > 100 || sampleRate < 0) { + _throwInternal(logger, eLoggingSeverity.WARNING, + _eInternalMessageId.SampleRateOutOfRange, + "Sampling rate is out of range (0..100). Sampling will be disabled, you may be sending too much data which may affect your AI service level.", + { samplingRate: sampleRate }, true); + sampleRate = 100; + } - if (samplingPercentage === null || samplingPercentage === undefined || samplingPercentage >= 100) { - return true; - } else if (envelope.baseType === Metric.dataType) { - // exclude MetricData telemetry from sampling - return true; + let sampler: ISample & { generator: IScoreGenerator } = { + sampleRate: sampleRate, + generator: _samplingScoreGenerator, + isSampledIn: function (envelope: ITelemetryItem) { + return _isSampledIn(envelope, sampler.sampleRate, sampler.generator); } + }; - isSampledIn = this.samplingScoreGenerator.getSamplingScore(envelope) < samplingPercentage; - return isSampledIn; - } + return sampler; } diff --git a/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/HashCodeScoreGenerator.ts b/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/HashCodeScoreGenerator.ts index 1f6f9ee96..da0b724ad 100644 --- a/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/HashCodeScoreGenerator.ts +++ b/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/HashCodeScoreGenerator.ts @@ -1,23 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { mathAbs } from "@nevware21/ts-utils"; + // (Magic number) DJB algorithm can't work on shorter strings (results in poor distribution const MIN_INPUT_LENGTH: number = 8; -export class HashCodeScoreGenerator { - // We're using 32 bit math, hence max value is (2^31 - 1) - public static INT_MAX_VALUE: number = 2147483647; +// We're using 32 bit math, hence max value is (2^31 - 1) +const INT_MAX_VALUE: number = 2147483647; - public getHashCodeScore(key: string): number { - const score = this.getHashCode(key) / HashCodeScoreGenerator.INT_MAX_VALUE; - return score * 100; - } - - public getHashCode(input: string): number { - if (input === "") { - return 0; - } +export function getHashCodeScore(key: string): number { + let score = 0; + let input = key; + if (input) { while (input.length < MIN_INPUT_LENGTH) { input = input.concat(input); } @@ -32,6 +28,8 @@ export class HashCodeScoreGenerator { hash = hash & hash; } - return Math.abs(hash); + score = mathAbs(hash) / INT_MAX_VALUE; } + + return score * 100; } diff --git a/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/SamplingScoreGenerator.ts b/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/SamplingScoreGenerator.ts index 7b132f01f..0c3cc51bc 100644 --- a/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/SamplingScoreGenerator.ts +++ b/channels/applicationinsights-channel-js/src/TelemetryProcessors/SamplingScoreGenerators/SamplingScoreGenerator.ts @@ -3,27 +3,26 @@ import { ContextTagKeys } from "@microsoft/applicationinsights-common"; import { ITelemetryItem } from "@microsoft/applicationinsights-core-js"; -import { HashCodeScoreGenerator } from "./HashCodeScoreGenerator"; +import { getHashCodeScore } from "./HashCodeScoreGenerator"; -export class SamplingScoreGenerator { - - public getSamplingScore: (item: ITelemetryItem) => number; +export interface IScoreGenerator { + getScore(item: ITelemetryItem): number; +} - constructor() { - let _self = this; - let hashCodeGenerator: HashCodeScoreGenerator = new HashCodeScoreGenerator(); - let keys: ContextTagKeys = new ContextTagKeys(); +export function createSamplingScoreGenerator(): IScoreGenerator { + let keys: ContextTagKeys = new ContextTagKeys(); - _self.getSamplingScore = (item: ITelemetryItem): number => { + return { + getScore: (item: ITelemetryItem): number => { let score: number = 0; if (item.tags && item.tags[keys.userId]) { // search in tags first, then ext - score = hashCodeGenerator.getHashCodeScore(item.tags[keys.userId]); + score = getHashCodeScore(item.tags[keys.userId]); } else if (item.ext && item.ext.user && item.ext.user.id) { - score = hashCodeGenerator.getHashCodeScore(item.ext.user.id); + score = getHashCodeScore(item.ext.user.id); } else if (item.tags && item.tags[keys.operationId]) { // search in tags first, then ext - score = hashCodeGenerator.getHashCodeScore(item.tags[keys.operationId]); + score = getHashCodeScore(item.tags[keys.operationId]); } else if (item.ext && item.ext.telemetryTrace && item.ext.telemetryTrace.traceID) { - score = hashCodeGenerator.getHashCodeScore(item.ext.telemetryTrace.traceID); + score = getHashCodeScore(item.ext.telemetryTrace.traceID); } else { // tslint:disable-next-line:insecure-random score = (Math.random() * 100); @@ -31,5 +30,5 @@ export class SamplingScoreGenerator { return score; } - } + }; } diff --git a/common/Tests/Framework/src/AITestClass.ts b/common/Tests/Framework/src/AITestClass.ts index 34fcef562..e14c8d3bb 100644 --- a/common/Tests/Framework/src/AITestClass.ts +++ b/common/Tests/Framework/src/AITestClass.ts @@ -1156,6 +1156,18 @@ export class AITestClass { } } + protected _clearSessionStorage() { + if (window.sessionStorage) { + window.sessionStorage.clear(); + } + } + + protected _clearLocalStorage() { + if (window.localStorage) { + window.localStorage.clear(); + } + } + protected _disableDynProtoBaseFuncs(dynamicProtoInst: typeof dynamicProto = dynamicProto) { let defOpts = dynamicProtoInst["_dfOpts"]; if (defOpts) { @@ -1226,7 +1238,10 @@ export class AITestClass { let _self = this; // Initialize the sandbox similar to what is done in sinon.js "test()" override. See note on class. _self.sandbox = createSandbox(this.sandboxConfig); - + // Clear out all cookies + _self._deleteAllCookies(); + _self._clearSessionStorage(); + _self._clearLocalStorage(); if (_self.isEmulatingIe) { // Reset any previously cached values, which may have grabbed the mocked values diff --git a/common/Tests/Framework/src/Assert.ts b/common/Tests/Framework/src/Assert.ts index f55891bd5..14cdd8111 100644 --- a/common/Tests/Framework/src/Assert.ts +++ b/common/Tests/Framework/src/Assert.ts @@ -1,5 +1,6 @@ /// +import { dumpObj } from "@nevware21/ts-utils"; import { expectedToString, stateToString } from "./DebugHelpers"; /** @@ -147,6 +148,15 @@ export class Assert { return QUnit.assert.throws(block, expected, message || expectedToString(expected)); } + + public static doesNotThrow(block: () => any, message?: string): any { + try { + return block(); + } catch (e) { + return Assert.fail((message || "Expected no exception, but got") + ": " + dumpObj(e)); + } + } + /** * Fails a test with the given message. * diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index 69d4cddc9..836675057 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -33,6 +33,7 @@ "@rush-temp/applicationinsights-example-cfgsync": "file:./projects/applicationinsights-example-cfgsync.tgz", "@rush-temp/applicationinsights-example-dependencies": "file:./projects/applicationinsights-example-dependencies.tgz", "@rush-temp/applicationinsights-example-shared-worker": "file:./projects/applicationinsights-example-shared-worker.tgz", + "@rush-temp/applicationinsights-example-startspan": "file:./projects/applicationinsights-example-startspan.tgz", "@rush-temp/applicationinsights-js-release-tools": "file:./projects/applicationinsights-js-release-tools.tgz", "@rush-temp/applicationinsights-offlinechannel-js": "file:./projects/applicationinsights-offlinechannel-js.tgz", "@rush-temp/applicationinsights-osplugin-js": "file:./projects/applicationinsights-osplugin-js.tgz", @@ -1140,6 +1141,25 @@ "typescript": "^4.9.3" } }, + "node_modules/@rush-temp/applicationinsights-example-startspan": { + "version": "0.0.0", + "resolved": "file:projects/applicationinsights-example-startspan.tgz", + "integrity": "sha512-lPbUtyodExCKyq3SdekKrlY3nKfurk5bYLLFhDFZ2pv9/VrlYtlRDL6ofVnORn67gvTSLSNkeULI3ptOtlSFMQ==", + "dependencies": { + "@microsoft/dynamicproto-js": "^2.0.3", + "@nevware21/ts-utils": ">= 0.11.8 < 2.x", + "@rollup/plugin-commonjs": "^24.0.0", + "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-replace": "^5.0.2", + "grunt": "^1.5.3", + "grunt-cli": "^1.4.3", + "rollup": "^3.20.0", + "rollup-plugin-cleanup": "^3.2.1", + "rollup-plugin-sourcemaps": "^0.6.3", + "tslib": ">= 1.0.0", + "typescript": "^4.9.3" + } + }, "node_modules/@rush-temp/applicationinsights-js-release-tools": { "version": "0.0.0", "resolved": "file:projects/applicationinsights-js-release-tools.tgz", @@ -2947,9 +2967,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.5.263", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.263.tgz", - "integrity": "sha512-DrqJ11Knd+lo+dv+lltvfMDLU27g14LMdH2b0O3Pio4uk0x+z7OR+JrmyacTPN2M8w3BrZ7/RTwG3R9B7irPlg==" + "version": "1.5.264", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.264.tgz", + "integrity": "sha512-1tEf0nLgltC3iy9wtlYDlQDc5Rg9lEKVjEmIHJ21rI9OcqkvD45K1oyNIRA4rR1z3LgJ7KeGzEBojVcV6m4qjA==" }, "node_modules/emoji-regex": { "version": "8.0.0", diff --git a/docs/OTel/README.md b/docs/OTel/README.md new file mode 100644 index 000000000..304f6564f --- /dev/null +++ b/docs/OTel/README.md @@ -0,0 +1,413 @@ +# OpenTelemetry Tracing API in Application Insights JavaScript SDK + +## Overview + +The Application Insights JavaScript SDK provides an OpenTelemetry (OTel) compatible tracing API, allowing you to instrument your applications using familiar OpenTelemetry-like APIs for distributed tracing while automatically sending telemetry to Azure Application Insights. This implementation focuses on **tracing support only** (not metrics or logs) and bridges the gap between OpenTelemetry's vendor-neutral instrumentation patterns and Application Insights' powerful monitoring capabilities. + +**Note:** This is an OpenTelemetry-compatible tracing API implementation, not a full OpenTelemetry SDK. It provides a Tracing API interface following OpenTelemetry conventions but does not include all span operations, metrics or logging APIs. + +## What is OpenTelemetry? + +OpenTelemetry is an open-source observability framework for cloud-native software. It provides a single set of APIs, libraries, and conventions for capturing distributed traces, metrics, and logs from your applications. + +**Application Insights Implementation:** The Application Insights JavaScript SDK implements an OpenTelemetry like compatiible tracing API. This means you can use familiar OpenTelemetry tracing patterns for distributed trace instrumentation. However, this is not a full OpenTelemetry SDK implementation - only the tracing API is supported (metrics and logs APIs are not included). + +## Why Use the OpenTelemetry-compatible Tracing API? + +- **Familiar API**: Use OpenTelemetry-like tracing APIs following industry-standard patterns +- **Tracing Standards**: Implement distributed tracing using OpenTelemetry conventions +- **Automatic Telemetry**: Spans automatically create Application Insights trace telemetry +- **Rich Context**: Full distributed tracing with parent-child span relationships +- **Service Identification**: Organize traces by service name and version +- **Type Safety**: Full TypeScript support with proper interfaces +- **Backward Compatibility**: Works alongside existing Application Insights code +- **Single SDK**: No need for separate OpenTelemetry packages for tracing + +## Core Concepts + +### 1. **OTel API** ([`IOTelApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelApi.html)) + +The main entry point for OpenTelemetry functionality. Provides access to tracers and the trace API. + +### 2. **Trace API** ([`ITraceApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceApi.html)) + +Manages tracer instances and provides utilities for span context management. + +### 3. **Tracer** ([`IOTelTracer`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracer.html)) + +Creates and manages spans. Each tracer typically represents a specific component or service. + +### 4. **Span** ([`IReadableSpan`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IReadableSpan.html)) + +Represents a unit of work in a distributed trace. Contains timing, attributes, and context information. + +### 5. **Standalone Helper Functions** + +- **`withSpan`**: Executes code with a span as the active context +- **`useSpan`**: Similar to `withSpan` but provides the span scope as a parameter +- **`wrapSpanContext`**: Creates a non-recording span from a span context +- **`isSpanContextValid`**: Validates span context information + +### 6. **Application Insights Functions** ([`ITraceHost`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceHost.html)) + +Direct access to OpenTelemetry trace operations available on `appInsights` or `core` instances: +- **`startSpan(name, options?, parent?)`**: Create a new span with explicit parent control +- **`getActiveSpan(createNew?)`**: Get the currently active span +- **`setActiveSpan(span)`**: Set a span as the active span and manage context + +These methods provide direct control over span lifecycle and context management as the main appInsights manages an +internal **Tracer** ([`IOTelTracer`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracer.html)). + +## Quick Start + +### Installation + +The OpenTelemetry-compatible tracing APIs are built into the Application Insights packages: + +```bash +npm install @microsoft/applicationinsights-web +# or for core only +npm install @microsoft/applicationinsights-core-js +``` + +**No additional OpenTelemetry packages are required** for tracing. The tracing API is included in the core SDK. + +### Basic Usage + +```typescript +import { ApplicationInsights } from '@microsoft/applicationinsights-web'; + +// Initialize Application Insights +const appInsights = new ApplicationInsights({ + config: { + connectionString: 'YOUR_CONNECTION_STRING' + } +}); +appInsights.loadAppInsights(); + +// Get the OpenTelemetry API +const otelApi = appInsights.otelApi; + +// Get a tracer for your service +const tracer = otelApi.trace.getTracer('my-service'); + +// Create a span +const span = tracer.startSpan('user-operation'); +span.setAttribute('user.id', '12345'); +span.setAttribute('operation.type', 'checkout'); + +try { + // Perform your operation + await processCheckout(); + span.setStatus({ code: SpanStatusCode.OK }); +} catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); +} finally { + span.end(); // Always end the span +} +``` + +### Using `startActiveSpan` (Recommended) + +The `startActiveSpan` method provides automatic context management: + +```typescript +const result = tracer.startActiveSpan('process-payment', async (span) => { + span.setAttribute('payment.method', 'credit_card'); + + try { + const response = await processPayment(); + span.setAttribute('payment.status', 'success'); + return response; + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + throw error; + } + // Span is automatically ended when function completes +}); +``` + +### Using startSpan Directly on the SKU (Recommended) + +The Application Insights SKU instance (`appInsights`) is itself a tracer, so you can call `startSpan` directly without obtaining a tracer first. **This is the recommended approach for most scenarios.** + +```typescript +// Direct usage - SKU is a tracer (Recommended) +const span = appInsights.startSpan('quick-operation', { + kind: OTelSpanKind.INTERNAL, + attributes: { + 'user.id': '12345', + 'operation.type': 'data-fetch' + } +}); + +if (span) { + try { + // Perform operation + const data = await fetchData(); + span.setAttribute('items.count', data.length); + span.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + } finally { + span.end(); + } +} +``` + +**When to use direct `startSpan` vs obtaining a tracer:** +- **Use `appInsights.startSpan()` directly** (Recommended) - For most scenarios, provides simple and direct span creation +- **Use `appInsights.trace.getTracer('service-name')`** - Only when you need to organize spans by different services or components with specific names for better categorization + +Both approaches create identical telemetry, but using the direct `startSpan` method is simpler and recommended for most applications. + +## Documentation Structure + +This folder contains comprehensive documentation on all OpenTelemetry features: + +### Core API Documentation +- **[OTel API](./otelApi.md)** - Main OpenTelemetry API interface and creation +- **[Trace API](./traceApi.md)** - Tracer management and span utilities + +### Helper Functions +- **[withSpan](./withSpan.md)** - Execute code with active span context +- **[useSpan](./useSpan.md)** - Execute code with span scope parameter + +### Guides and Examples +- **[Examples](./examples.md)** - Comprehensive usage examples and patterns + +## Key Features + +### Automatic Telemetry Creation + +When you end a span, Application Insights automatically: +- Creates trace telemetry with full context +- Includes all span attributes as custom properties +- Preserves parent-child relationships via trace/span IDs +- Calculates accurate timing and duration + +### Nested Spans + +Create hierarchical traces with parent-child relationships: + +```typescript +tracer.startActiveSpan('parent-operation', (parentSpan) => { + parentSpan.setAttribute('step', 'starting'); + + // Child spans automatically inherit parent context + tracer.startActiveSpan('child-operation', (childSpan) => { + childSpan.setAttribute('detail', 'processing'); + // Work here + }); + + parentSpan.setAttribute('step', 'completed'); +}); +``` + +### Distributed Tracing + +Propagate trace context across service boundaries: + +```typescript +// Service A: Create a span and extract context +const span = tracer.startSpan('api-call'); +const traceContext = span.spanContext(); + +// Pass traceContext to Service B (e.g., via HTTP headers) +const headers = { + 'traceparent': `00-${traceContext.traceId}-${traceContext.spanId}-01` +}; + +// Service B: Create child span from propagated context +const childSpan = tracer.startSpan('process-request', { + parent: traceContext +}); +``` + +### Multiple Services/Components + +Use different tracers for different parts of your application: + +```typescript +const userServiceTracer = otelApi.trace.getTracer('user-service'); +const paymentTracer = otelApi.trace.getTracer('payment-service'); + +// Each tracer can be used independently +const userSpan = userServiceTracer.startSpan('authenticate'); +const paymentSpan = paymentTracer.startSpan('process-payment'); +``` + +## Browser Compatibility + +The OpenTelemetry implementation in Application Insights JavaScript SDK supports: + +- Modern browsers (Chrome, Firefox, Safari, Edge) - ES5 target +- Internet Explorer 8+ (with ES5 polyfills) +- Mobile browsers (iOS Safari, Android Chrome) +- Non-browser runtimes (Node.js, Web Workers) + +## Performance Considerations + +The OpenTelemetry implementation is designed for minimal performance impact: + +- **Lazy Initialization**: APIs are created only when accessed +- **Efficient Caching**: Tracers are cached and reused +- **Non-blocking**: Span operations don't block the browser UI +- **Minimal Allocations**: Optimized to reduce memory pressure +- **Tree-shakable**: Unused code can be eliminated by bundlers + +## Migration Guide + +### From OpenTelemetry SDK + +If you're migrating from `@opentelemetry/api`: + +1. Replace `@opentelemetry/api` tracing imports with Application Insights imports +2. Use `appInsights.otelApi` or `appInsights.trace` to access the tracing API +3. Most tracing API signatures are compatible +4. Telemetry automatically flows to Application Insights + +**Important Limitations:** +- Only tracing APIs are supported (no metrics or logs APIs) +- This is an OpenTelemetry-compatible implementation, not the official OpenTelemetry SDK +- Some advanced OpenTelemetry features may not be available + +### Multiple Ways to Create Spans + +Application Insights provides several methods to create spans: + +**Using Direct startSpan (Recommended):** +```typescript +const span = appInsights.startSpan('operation', options); +``` + +**Using Tracer API (for organizing by service):** +```typescript +const tracer = appInsights.trace.getTracer('my-service'); +const span = tracer.startSpan('operation', options); +``` + +**Using startActiveSpan (for automatic context management):** +```typescript +appInsights.startActiveSpan('operation', (span) => { + // Work with automatic context and lifecycle management +}); +``` + +The direct `startSpan` method is recommended for most scenarios. Use tracers when you need to organize spans by service name. + +## API Compatibility + +This implementation provides an OpenTelemetry-compatible tracing API based on OpenTelemetry API v1.9.0 specifications: + +- **Trace API v1.x** - Core tracing functionality (tracers, spans, context) +- **Context API v1.x** - Active span context management (via ITraceHost) +- **Span interface specifications** - Compatible span attributes, events, and status + +**Scope:** Only tracing APIs are implemented. This is not a complete OpenTelemetry SDK - metrics and logs APIs are not included. + +## Best Practices + +1. **Use `startActiveSpan` for most scenarios** - Provides automatic lifecycle management +2. **Always end spans** - Either manually or via `startActiveSpan` +3. **Set span status** - Indicate success/failure explicitly with status codes +4. **Use descriptive names** - Span names should clearly identify operations +5. **Add relevant attributes** - Enrich spans with contextual information +6. **Use service-specific tracers** - Organize telemetry by service/component + +## Common Patterns + +### Error Handling + +```typescript +const span = tracer.startSpan('risky-operation'); +try { + await performOperation(); + span.setStatus({ code: SpanStatusCode.OK }); +} catch (error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + span.setAttribute('error', true); + span.setAttribute('error.message', error.message); + throw error; +} finally { + span.end(); +} +``` + +### Async Operations + +```typescript +tracer.startActiveSpan('async-work', async (span) => { + span.setAttribute('started', Date.now()); + + const result = await doAsyncWork(); + + span.setAttribute('completed', Date.now()); + return result; +}); +``` + +### Background Tasks + +```typescript +import { withSpan } from '@microsoft/applicationinsights-core-js'; + +const span = tracer.startSpan('background-task'); +withSpan(appInsights.core, span, async () => { + // All nested operations inherit this span context + await processBackgroundWork(); +}); +span.end(); +``` + +## Troubleshooting + +### Spans Not Creating Telemetry + +- Ensure spans are ended with `span.end()` +- Check that Application Insights is initialized +- Verify instrumentation key is correct + +### Missing Parent-Child Relationships + +- Use `startActiveSpan` or `withSpan`/`useSpan` helpers +- Ensure parent span is active when creating child spans +- Check that span contexts are properly propagated + +### Performance Issues + +- Don't create excessive spans in tight loops +- Use sampling if generating high volume of traces +- Consider span lifecycle and ensure spans are ended promptly + +## Additional Resources + +- [Application Insights Documentation](https://docs.microsoft.com/azure/azure-monitor/app/javascript) +- [OpenTelemetry Specification](https://opentelemetry.io/docs/specs/otel/) +- [Distributed Tracing Guide](../Dependency.md) +- [Performance Monitoring](../PerformanceMonitoring.md) + +## Support and Feedback + +For issues, questions, or feedback: +- [GitHub Issues](https://github.com/microsoft/ApplicationInsights-JS/issues) +- [Stack Overflow](https://stackoverflow.com/questions/tagged/azure-application-insights) + +--- + +**Next Steps:** +- Read the [OTel API documentation](./otelApi.md) for detailed API reference +- Explore [comprehensive examples](./examples.md) for common scenarios +- Learn about [withSpan](./withSpan.md) and [useSpan](./useSpan.md) helpers diff --git a/docs/OTel/examples.md b/docs/OTel/examples.md new file mode 100644 index 000000000..0ed48ba9c --- /dev/null +++ b/docs/OTel/examples.md @@ -0,0 +1,1289 @@ +# OpenTelemetry Examples and Patterns + +## Overview + +This guide provides examples and patterns for using the OpenTelemetry APIs in the Application Insights JavaScript SDK. These examples demonstrate real-world scenarios and best practices for instrumentation. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Basic Patterns](#basic-patterns) +- [Advanced Patterns](#advanced-patterns) +- [Real-World Scenarios](#real-world-scenarios) +- [Integration Examples](#integration-examples) +- [Performance Patterns](#performance-patterns) +- [Testing and Debugging](#testing-and-debugging) + +## Getting Started + +### Setup + +```typescript +import { ApplicationInsights } from '@microsoft/applicationinsights-web'; +import { OTelSpanKind, SpanStatusCode } from '@microsoft/applicationinsights-core-js'; + +// Initialize Application Insights +const appInsights = new ApplicationInsights({ + config: { + connectionString: 'YOUR_CONNECTION_STRING', + enableAutoRouteTracking: true + } +}); + +appInsights.loadAppInsights(); + +// Get the OpenTelemetry API +const otelApi = appInsights.otelApi; +const trace = otelApi.trace; + +// Create a tracer for your application +const tracer = trace.getTracer('my-application'); +``` + +## Basic Patterns + +### Pattern 1: Simple Span Creation + +```typescript +// Create and end a simple span +function trackOperation(operationName: string): void { + const span = tracer.startSpan(operationName); + + if (span) { + span.setAttribute('operation.type', 'manual'); + span.setAttribute('timestamp', Date.now()); + + try { + // Perform operation + performWork(); + + span.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + } finally { + span.end(); + } + } +} + +// Usage +trackOperation('user-action'); +``` + +### Pattern 2: Using startActiveSpan + +```typescript +// Automatic span lifecycle management +async function fetchUserData(userId: string): Promise { + return tracer.startActiveSpan('fetch-user-data', async (span) => { + span.setAttribute('user.id', userId); + span.setAttribute('operation', 'read'); + + try { + const response = await fetch(`/api/users/${userId}`); + + span.setAttribute('http.status_code', response.status); + span.setAttribute('http.method', 'GET'); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + span.setStatus({ code: SpanStatusCode.OK }); + + return data; + } catch (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw error; + } + // Span automatically ended + }); +} + +// Usage +const userData = await fetchUserData('user123'); +``` + +### Pattern 3: Parent-Child Spans + +```typescript +async function processOrder(orderId: string): Promise { + return tracer.startActiveSpan('process-order', async (parentSpan) => { + parentSpan.setAttribute('order.id', orderId); + parentSpan.setAttributes({ + 'operation': 'checkout', + 'service': 'order-processing' + }); + + // Child span 1: Validate inventory + await tracer.startActiveSpan('validate-inventory', async (span) => { + span.setAttribute('check.type', 'inventory'); + const available = await checkInventory(orderId); + span.setAttribute('inventory.available', available); + }); + + // Child span 2: Process payment + const paymentResult = await tracer.startActiveSpan('process-payment', async (span) => { + span.setAttribute('payment.method', 'credit_card'); + const result = await processPayment(orderId); + span.setAttribute('payment.success', result.success); + return result; + }); + + // Child span 3: Send notification + await tracer.startActiveSpan('send-notification', async (span) => { + span.setAttribute('notification.type', 'email'); + await sendOrderConfirmation(orderId); + }); + + parentSpan.setAttribute('order.completed', true); + return { orderId, payment: paymentResult }; + }); +} +``` + +### Pattern 4: Multiple Tracers + +```typescript +// Create tracers for different services/components +class Application { + private userTracer = trace.getTracer('user-service'); + private apiTracer = trace.getTracer('api-client'); + private dbTracer = trace.getTracer('database-layer'); + + async authenticateUser(username: string, password: string) { + return this.userTracer.startActiveSpan('authenticate', async (span) => { + span.setAttribute('auth.username', username); + span.setAttribute('auth.method', 'password'); + + // Use database tracer for DB operations + const user = await this.dbTracer.startActiveSpan('query-user', async (dbSpan) => { + dbSpan.setAttribute('db.operation', 'SELECT'); + dbSpan.setAttribute('db.table', 'users'); + return await this.queryUser(username); + }); + + // Use API tracer for external API calls + await this.apiTracer.startActiveSpan('verify-token', async (apiSpan) => { + apiSpan.setAttribute('api.endpoint', '/verify'); + await this.verifyWithExternalService(user.token); + }); + + return user; + }); + } +} +``` + +## Advanced Patterns + +### Pattern 5: Context Propagation Across Async Boundaries + +```typescript +import { withSpan } from '@microsoft/applicationinsights-core-js'; + +// Preserve context across setTimeout/setInterval +function scheduleWithContext(fn: () => void, delay: number): void { + const span = trace.getActiveSpan(); + + setTimeout(() => { + if (span) { + withSpan(appInsights.core, span, fn); + } else { + fn(); + } + }, delay); +} + +// Usage +tracer.startActiveSpan('parent-operation', (span) => { + span.setAttribute('scheduling', 'async-task'); + + scheduleWithContext(() => { + // This executes with parent span context + console.log('Active span:', trace.getActiveSpan()?.name); + }, 1000); +}); +``` + +### Pattern 6: Distributed Tracing + +```typescript +// Service A: Create span and propagate context +class ServiceA { + async callServiceB(data: any): Promise { + return tracer.startActiveSpan('call-service-b', async (span) => { + span.setAttribute('service.target', 'service-b'); + + // Get span context for propagation + const spanContext = span.spanContext(); + + // Create W3C traceparent header + const traceparent = `00-${spanContext.traceId}-${spanContext.spanId}-01`; + + const response = await fetch('http://service-b/api/process', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'traceparent': traceparent + }, + body: JSON.stringify(data) + }); + + span.setAttribute('http.status_code', response.status); + return response.json(); + }); + } +} + +// Service B: Receive and continue trace +class ServiceB { + handleRequest(headers: Record, data: any): any { + // Parse traceparent header + const traceparent = headers['traceparent']; + const [version, traceId, parentSpanId, flags] = traceparent.split('-'); + + // Create context from propagated info + const parentContext = { + traceId: traceId, + spanId: parentSpanId, + traceFlags: parseInt(flags, 16) + }; + + // Validate and wrap context + if (trace.isSpanContextValid(parentContext)) { + const wrappedSpan = trace.wrapSpanContext(parentContext); + const scope = trace.setActiveSpan(wrappedSpan); + + try { + // Create child span that continues the distributed trace + return tracer.startActiveSpan('process-request', (span) => { + span.setAttribute('service', 'service-b'); + span.setAttribute('data.size', JSON.stringify(data).length); + + return this.processData(data); + }); + } finally { + scope?.restore(); + } + } else { + // Process without trace context + return this.processData(data); + } + } +} +``` + +### Pattern 7: Conditional Tracing + +```typescript +class TracingConfig { + private enableTracing: boolean = true; + private sampleRate: number = 1.0; // 100% + + shouldTrace(): boolean { + return this.enableTracing && Math.random() < this.sampleRate; + } + + setSampleRate(rate: number): void { + this.sampleRate = Math.max(0, Math.min(1, rate)); + } +} + +const config = new TracingConfig(); + +function conditionalTrace( + operationName: string, + fn: () => T +): T { + if (!config.shouldTrace()) { + return fn(); + } + + return tracer.startActiveSpan(operationName, (span) => { + span.setAttribute('sampled', true); + return fn(); + }); +} + +// Usage +const result = conditionalTrace('expensive-operation', () => { + return performExpensiveOperation(); +}); +``` + +### Pattern 8: Span Suppression + +```typescript +import { suppressTracing, unsuppressTracing, isTracingSuppressed } from '@microsoft/applicationinsights-core-js'; + +// Suppress tracing for specific operations +function performWithoutTracing(fn: () => T): T { + const wasSupressed = isTracingSuppressed(appInsights.core); + + if (!wasSupressed) { + suppressTracing(appInsights.core); + } + + try { + return fn(); + } finally { + if (!wasSupressed) { + unsuppressTracing(appInsights.core); + } + } +} + +// Usage +tracer.startActiveSpan('parent', (span) => { + span.setAttribute('traced', true); + + // This won't create spans + performWithoutTracing(() => { + const innerSpan = tracer.startSpan('should-not-trace'); + console.log('Inner span:', innerSpan); // null + }); + + // This will create spans again + const childSpan = tracer.startSpan('will-trace'); + console.log('Child span:', childSpan); // span object +}); +``` + +## Real-World Scenarios + +### Scenario 1: E-Commerce Checkout Flow + +```typescript +class CheckoutService { + private tracer = trace.getTracer('checkout-service'); + + async checkout(cartId: string, userId: string): Promise { + return this.tracer.startActiveSpan('checkout', async (checkoutSpan) => { + checkoutSpan.setAttributes({ + 'cart.id': cartId, + 'user.id': userId, + 'checkout.started': new Date().toISOString() + }); + + try { + // Step 1: Validate cart + const cart = await this.tracer.startActiveSpan('validate-cart', async (span) => { + span.setAttribute('cart.id', cartId); + const cart = await this.getCart(cartId); + span.setAttribute('cart.items', cart.items.length); + span.setAttribute('cart.total', cart.total); + + if (cart.items.length === 0) { + throw new Error('Cart is empty'); + } + + return cart; + }); + + // Step 2: Calculate totals + const totals = await this.tracer.startActiveSpan('calculate-totals', async (span) => { + span.setAttribute('cart.subtotal', cart.subtotal); + const totals = await this.calculateTotals(cart); + span.setAttributes({ + 'cart.tax': totals.tax, + 'cart.shipping': totals.shipping, + 'cart.total': totals.total + }); + return totals; + }); + + // Step 3: Process payment + const payment = await this.tracer.startActiveSpan('process-payment', async (span) => { + span.setAttributes({ + 'payment.amount': totals.total, + 'payment.currency': 'USD', + 'payment.method': 'credit_card' + }); + + const result = await this.processPayment(userId, totals.total); + + span.setAttributes({ + 'payment.transaction_id': result.transactionId, + 'payment.status': result.status, + 'payment.success': result.success + }); + + if (!result.success) { + throw new Error('Payment failed'); + } + + return result; + }); + + // Step 4: Create order + const order = await this.tracer.startActiveSpan('create-order', async (span) => { + span.setAttribute('order.user_id', userId); + const order = await this.createOrder(userId, cart, payment); + span.setAttributes({ + 'order.id': order.id, + 'order.status': order.status, + 'order.created': order.createdAt + }); + return order; + }); + + // Step 5: Send confirmations + await this.tracer.startActiveSpan('send-confirmations', async (span) => { + span.setAttribute('order.id', order.id); + + await Promise.all([ + this.sendEmailConfirmation(userId, order), + this.sendSMSNotification(userId, order) + ]); + + span.setAttribute('notifications.sent', 2); + }); + + checkoutSpan.setAttributes({ + 'checkout.completed': new Date().toISOString(), + 'checkout.success': true, + 'order.id': order.id + }); + + checkoutSpan.setStatus({ code: SpanStatusCode.OK }); + + return order; + + } catch (error) { + checkoutSpan.recordException(error); + checkoutSpan.setAttributes({ + 'checkout.error': error.message, + 'checkout.success': false + }); + checkoutSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + throw error; + } + }); + } +} +``` + +### Scenario 2: API Client with Retry Logic + +```typescript +class RetryableAPIClient { + private tracer = trace.getTracer('api-client'); + private maxRetries = 3; + private retryDelay = 1000; + + async fetchWithRetry( + url: string, + options?: RequestInit + ): Promise { + return this.tracer.startActiveSpan('fetch-with-retry', async (span) => { + span.setAttributes({ + 'http.url': url, + 'http.method': options?.method || 'GET', + 'retry.max_attempts': this.maxRetries + }); + + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this.maxRetries; attempt++) { + const attemptSpan = this.tracer.startSpan(`attempt-${attempt}`); + + try { + attemptSpan?.setAttributes({ + 'retry.attempt': attempt, + 'retry.is_retry': attempt > 1 + }); + + const response = await fetch(url, options); + + attemptSpan?.setAttributes({ + 'http.status_code': response.status, + 'http.response_size': response.headers.get('content-length') + }); + + if (response.ok) { + attemptSpan?.setStatus({ code: SpanStatusCode.OK }); + attemptSpan?.end(); + + span.setAttributes({ + 'retry.succeeded_on_attempt': attempt, + 'http.status_code': response.status + }); + span.setStatus({ code: SpanStatusCode.OK }); + + return response.json(); + } + + throw new Error(`HTTP ${response.status}`); + + } catch (error) { + lastError = error as Error; + + attemptSpan?.recordException(error); + attemptSpan?.setAttributes({ + 'retry.error': error.message, + 'retry.failed': true + }); + attemptSpan?.setStatus({ code: SpanStatusCode.ERROR }); + attemptSpan?.end(); + + if (attempt < this.maxRetries) { + span.setAttribute(`retry.attempt_${attempt}_failed`, true); + await this.delay(this.retryDelay * attempt); + } + } + } + + // All retries failed + span.recordException(lastError!); + span.setAttributes({ + 'retry.exhausted': true, + 'retry.final_error': lastError!.message + }); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: 'All retry attempts failed' + }); + + throw lastError; + }); + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +// Usage +const client = new RetryableAPIClient(); +const data = await client.fetchWithRetry('/api/data'); +``` + +### Scenario 3: Background Job Processing + +```typescript +import { withSpan } from '@microsoft/applicationinsights-core-js'; + +class JobProcessor { + private tracer = trace.getTracer('job-processor'); + private processingJobs = new Map(); + + async processJob(job: Job): Promise { + const jobSpan = this.tracer.startSpan('process-job', { + kind: OTelSpanKind.CONSUMER, + attributes: { + 'job.id': job.id, + 'job.type': job.type, + 'job.priority': job.priority, + 'job.queued_at': job.queuedAt + } + }); + + if (!jobSpan) return; + + this.processingJobs.set(job.id, jobSpan); + + try { + await withSpan(appInsights.core, jobSpan, async () => { + jobSpan.setAttribute('job.started_at', new Date().toISOString()); + + // Process job steps + await this.validateJob(job); + await this.executeJob(job); + await this.finalizeJob(job); + + jobSpan.setAttributes({ + 'job.completed_at': new Date().toISOString(), + 'job.status': 'completed', + 'job.success': true + }); + + jobSpan.setStatus({ code: SpanStatusCode.OK }); + }); + + } catch (error) { + jobSpan.recordException(error); + jobSpan.setAttributes({ + 'job.status': 'failed', + 'job.error': error.message, + 'job.success': false + }); + jobSpan.setStatus({ code: SpanStatusCode.ERROR }); + + await this.handleJobFailure(job, error); + + } finally { + this.processingJobs.delete(job.id); + jobSpan.end(); + } + } + + private async validateJob(job: Job): Promise { + return this.tracer.startActiveSpan('validate-job', async (span) => { + span.setAttribute('job.id', job.id); + + // Validation logic + const isValid = await this.runValidation(job); + + span.setAttribute('job.valid', isValid); + + if (!isValid) { + throw new Error('Job validation failed'); + } + }); + } + + private async executeJob(job: Job): Promise { + return this.tracer.startActiveSpan('execute-job', async (span) => { + span.setAttribute('job.id', job.id); + + // Execute job-specific logic + const result = await this.runJobLogic(job); + + span.setAttributes({ + 'job.result.status': result.status, + 'job.result.items_processed': result.itemsProcessed + }); + }); + } + + private async finalizeJob(job: Job): Promise { + return this.tracer.startActiveSpan('finalize-job', async (span) => { + span.setAttribute('job.id', job.id); + + // Cleanup, notifications, etc. + await this.sendCompletionNotification(job); + await this.updateJobStatus(job, 'completed'); + + span.setAttribute('job.finalized', true); + }); + } +} +``` + +## Integration Examples + +### Integration 1: React Component Instrumentation + +```typescript +import React, { useEffect } from 'react'; +import { OTelSpanKind } from '@microsoft/applicationinsights-core-js'; + +const UserDashboard: React.FC<{ userId: string }> = ({ userId }) => { + const tracer = trace.getTracer('react-components'); + + useEffect(() => { + const span = tracer.startSpan('load-dashboard', { + kind: OTelSpanKind.INTERNAL, + attributes: { + 'component': 'UserDashboard', + 'user.id': userId + } + }); + + // Load data + loadDashboardData(userId) + .then(data => { + span?.setAttribute('data.loaded', true); + span?.setAttribute('data.items', data.length); + span?.setStatus({ code: SpanStatusCode.OK }); + }) + .catch(error => { + span?.recordException(error); + span?.setStatus({ code: SpanStatusCode.ERROR }); + }) + .finally(() => { + span?.end(); + }); + + return () => { + // Cleanup + }; + }, [userId]); + + const handleAction = (action: string) => { + tracer.startActiveSpan('user-action', (span) => { + span.setAttributes({ + 'action.type': action, + 'component': 'UserDashboard', + 'user.id': userId + }); + + performAction(action); + }); + }; + + return ( +
+ +
+ ); +}; +``` + +### Integration 2: Express Middleware + +```typescript +import express from 'express'; + +function tracingMiddleware( + req: express.Request, + res: express.Response, + next: express.NextFunction +): void { + const tracer = trace.getTracer('express-server'); + + const span = tracer.startSpan('http-request', { + kind: OTelSpanKind.SERVER, + attributes: { + 'http.method': req.method, + 'http.url': req.url, + 'http.route': req.route?.path, + 'http.user_agent': req.get('user-agent') + } + }); + + if (!span) { + return next(); + } + + // Store span in request for child spans + (req as any).span = span; + + const startTime = Date.now(); + + // Override res.end to capture response + const originalEnd = res.end; + res.end = function(...args: any[]) { + const duration = Date.now() - startTime; + + span.setAttributes({ + 'http.status_code': res.statusCode, + 'http.duration_ms': duration + }); + + if (res.statusCode >= 500) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `HTTP ${res.statusCode}` + }); + } else { + span.setStatus({ code: SpanStatusCode.OK }); + } + + span.end(); + + return originalEnd.apply(res, args); + }; + + next(); +} + +// Usage +const app = express(); +app.use(tracingMiddleware); +``` + +## Performance Patterns + +### Pattern 9: Batch Operations + +```typescript +class BatchProcessor { + private tracer = trace.getTracer('batch-processor'); + + async processBatch(items: T[], processor: (item: T) => Promise): Promise { + return this.tracer.startActiveSpan('process-batch', async (span) => { + span.setAttributes({ + 'batch.size': items.length, + 'batch.started': new Date().toISOString() + }); + + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + try { + await this.tracer.startActiveSpan(`process-item-${i}`, async (itemSpan) => { + itemSpan.setAttribute('item.index', i); + await processor(item); + successCount++; + }); + } catch (error) { + errorCount++; + span.recordException(error); + } + } + + span.setAttributes({ + 'batch.completed': new Date().toISOString(), + 'batch.success_count': successCount, + 'batch.error_count': errorCount, + 'batch.success_rate': successCount / items.length + }); + + if (errorCount > 0) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `${errorCount} items failed` + }); + } else { + span.setStatus({ code: SpanStatusCode.OK }); + } + }); + } +} +``` + +### Pattern 10: Lazy Loading with Tracing + +```typescript +class LazyLoader { + private tracer = trace.getTracer('lazy-loader'); + private cache = new Map(); + + async load(key: string, loader: () => Promise): Promise { + // Check cache first + if (this.cache.has(key)) { + return this.cache.get(key)!; + } + + return this.tracer.startActiveSpan('lazy-load', async (span) => { + span.setAttributes({ + 'cache.key': key, + 'cache.hit': false + }); + + const value = await loader(); + this.cache.set(key, value); + + span.setAttributes({ + 'cache.stored': true, + 'cache.size': this.cache.size + }); + + return value; + }); + } +} +``` + +## Using ITraceHost Methods Directly + +The [`ITraceHost`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceHost.html) interface provides direct access to span lifecycle methods through `appInsights` (AISKU) or `core` (AppInsightsCore). These methods give you lower-level control over span creation and context management. + +### Pattern: Direct startSpan Usage + +The `startSpan` method creates a new span without automatically setting it as the active span. This gives you explicit control over context management. + +```typescript +import { OTelSpanKind } from '@microsoft/applicationinsights-core-js'; + +// Using AISKU +const span = appInsights.startSpan('manual-operation', { + kind: OTelSpanKind.INTERNAL, + attributes: { + 'operation.type': 'manual', + 'user.id': 'user123' + } +}); + +if (span) { + try { + // Do work - span is NOT automatically active + performOperation(); + + // Manually add attributes as needed + span.setAttribute('result', 'success'); + span.setAttribute('items.processed', 42); + + span.setStatus({ code: eOTelSpanStatusCode.OK }); + } catch (error) { + span.recordException(error); + span.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: error.message + }); + } finally { + span.end(); + } +} +``` + +### Pattern: activeSpan for Context Awareness + +The `activeSpan` method retrieves the currently active span, useful for adding attributes or checking trace context. + +```typescript +// Check if there's an active span and add context +function addUserContextToActiveSpan(userId: string, userName: string): void { + const activeSpan = appInsights.getActiveSpan(); + + if (activeSpan && activeSpan.isRecording()) { + activeSpan.setAttribute('user.id', userId); + activeSpan.setAttribute('user.name', userName); + activeSpan.setAttribute('context.added.at', Date.now()); + } +} + +// Usage in your application +function handleUserAction(userId: string): void { + // Some parent span is already active + addUserContextToActiveSpan(userId, 'John Doe'); + + // Continue with operation... +} +``` + +### Pattern: activeSpan with createNew Parameter + +Control whether to create a non-recording span when no active span exists: + +```typescript +// Performance-optimized check - don't create non-recording span +function hasActiveTrace(): boolean { + const span = appInsights.getActiveSpan(false); + return span !== null; +} + +// Create non-recording span if needed (default behavior) +function getOrCreateActiveSpan(): IReadableSpan | null { + return appInsights.getActiveSpan(true); +} + +// Conditional tracing based on active context +function maybeTraceOperation(operationName: string): void { + if (hasActiveTrace()) { + // We're in a traced context, add details + const activeSpan = appInsights.getActiveSpan(); + activeSpan?.setAttribute('operation', operationName); + } + + performOperation(); +} +``` + +### Pattern: setActiveSpan for Manual Context Management + +The `setActiveSpan` method gives you explicit control over the active span context: + +```typescript +// Create and manually set a span as active +const parentSpan = appInsights.startSpan('parent-operation', { + kind: OTelSpanKind.SERVER, + attributes: { 'http.method': 'POST' } +}); + +if (parentSpan) { + // Set this span as the active span + const scope = appInsights.setActiveSpan(parentSpan); + + try { + // Any child spans created now will use parentSpan as parent + const childSpan = appInsights.startSpan('child-operation'); + + if (childSpan) { + // childSpan automatically has parentSpan as its parent + childSpan.setAttribute('child.data', 'value'); + childSpan.end(); + } + + parentSpan.setAttribute('children.created', 1); + + } finally { + // Restore previous active span + scope.restore(); + parentSpan.end(); + } +} +``` + +### Pattern: Scope Management with ISpanScope + +The [`ISpanScope`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ISpanScope.html) returned by `setActiveSpan` allows you to restore context: + +```typescript +function executeWithTemporaryContext(operationName: string): void { + // Create a temporary span context + const tempSpan = appInsights.startSpan(operationName); + + if (tempSpan) { + const scope = appInsights.setActiveSpan(tempSpan); + + try { + // Access the scope properties + console.log('Current span:', scope.span.name); + console.log('Previous span:', scope.prvSpan?.name || 'none'); + + // Do work in this context + performWork(); + + // Manually restore early if needed + scope.restore(); + + // Continue work outside the temporary context + performMoreWork(); + + } finally { + tempSpan.end(); + } + } +} +``` + +### Pattern: Combining Host Methods for Complex Workflows + +```typescript +import { OTelSpanKind, eOTelSpanStatusCode } from '@microsoft/applicationinsights-core-js'; + +class WorkflowEngine { + private executionCount = 0; + + executeWorkflow(workflowName: string, steps: Array<() => Promise>): Promise { + // Create a span for the entire workflow + const workflowSpan = appInsights.startSpan(`workflow.${workflowName}`, { + kind: OTelSpanKind.INTERNAL, + attributes: { + 'workflow.name': workflowName, + 'workflow.steps': steps.length, + 'execution.count': ++this.executionCount + } + }); + + if (!workflowSpan) { + return Promise.all(steps.map(step => step())).then(() => {}); + } + + // Set workflow span as active + const workflowScope = appInsights.setActiveSpan(workflowSpan); + + return (async () => { + try { + for (let i = 0; i < steps.length; i++) { + // Create a span for each step + const stepSpan = appInsights.startSpan(`workflow.step.${i + 1}`, { + kind: OTelSpanKind.INTERNAL, + attributes: { + 'step.index': i, + 'step.name': steps[i].name || `step-${i + 1}` + } + }); + + if (stepSpan) { + const stepScope = appInsights.setActiveSpan(stepSpan); + + try { + await steps[i](); + stepSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + } catch (error) { + stepSpan.recordException(error); + stepSpan.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: `Step ${i + 1} failed: ${error.message}` + }); + throw error; + } finally { + stepScope.restore(); + stepSpan.end(); + } + } else { + await steps[i](); + } + } + + workflowSpan.setAttribute('workflow.completed', true); + workflowSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + + } catch (error) { + workflowSpan.setAttribute('workflow.failed', true); + workflowSpan.recordException(error); + workflowSpan.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: `Workflow failed: ${error.message}` + }); + throw error; + + } finally { + workflowScope.restore(); + workflowSpan.end(); + } + })(); + } +} + +// Usage +const workflow = new WorkflowEngine(); + +workflow.executeWorkflow('user-onboarding', [ + async () => { /* validate input */ }, + async () => { /* create user account */ }, + async () => { /* send welcome email */ }, + async () => { /* initialize preferences */ } +]).then(() => { + console.log('Workflow completed successfully'); +}).catch(error => { + console.error('Workflow failed:', error); +}); +``` + +### Pattern: Accessing Active Span in Nested Functions + +```typescript +// Middleware pattern that checks active span +function withAuthentication(operation: () => T): T { + const activeSpan = appInsights.getActiveSpan(); + + // Add authentication context to active span if it exists + if (activeSpan && activeSpan.isRecording()) { + const authToken = getCurrentAuthToken(); + getActiveSpan.setAttribute('auth.method', authToken ? 'token' : 'anonymous'); + getActiveSpan.setAttribute('auth.validated', true); + } + + return operation(); +} + +// Usage - activeSpan is automatically propagated +const span = appInsights.startSpan('api-request'); +if (span) { + const scope = appInsights.setActiveSpan(span); + + try { + // The activeSpan in withAuthentication will be 'span' + const result = withAuthentication(() => { + return performSecureOperation(); + }); + + span.setAttribute('result', 'success'); + } finally { + scope.restore(); + span.end(); + } +} +``` + +## Testing and Debugging + +### Testing Pattern: Span Verification + +```typescript +import { isReadableSpan } from '@microsoft/applicationinsights-core-js'; + +describe('Tracing Tests', () => { + it('should create span with correct attributes', () => { + const span = tracer.startSpan('test-operation'); + + expect(span).toBeTruthy(); + expect(isReadableSpan(span)).toBe(true); + + span?.setAttribute('test.key', 'test-value'); + + const spanContext = span?.spanContext(); + expect(spanContext?.traceId).toBeTruthy(); + expect(spanContext?.spanId).toBeTruthy(); + + span?.end(); + }); + + it('should create parent-child relationship', () => { + tracer.startActiveSpan('parent', (parentSpan) => { + const parentContext = parentSpan.spanContext(); + + const childSpan = tracer.startSpan('child'); + const childContext = childSpan?.spanContext(); + + // Same trace ID + expect(childContext?.traceId).toBe(parentContext.traceId); + + // Different span IDs + expect(childContext?.spanId).not.toBe(parentContext.spanId); + + childSpan?.end(); + }); + }); + + it('should manage active span context correctly', () => { + const span1 = appInsights.startSpan('span-1'); + const span2 = appInsights.startSpan('span-2'); + + expect(span1).toBeTruthy(); + expect(span2).toBeTruthy(); + + // Set span1 as active + const scope1 = appInsights.setActiveSpan(span1!); + expect(appInsights.getActiveSpan(false)?.spanContext().spanId).toBe(span1!.spanContext().spanId); + + // Set span2 as active (nested) + const scope2 = appInsights.setActiveSpan(span2!); + expect(appInsights.getActiveSpan(false)?.spanContext().spanId).toBe(span2!.spanContext().spanId); + + // Restore to span1 + scope2.restore(); + expect(appInsights.getActiveSpan(false)?.spanContext().spanId).toBe(span1!.spanContext().spanId); + + // Restore to previous context + scope1.restore(); + + span1?.end(); + span2?.end(); + }); +}); +``` + +### Debugging Pattern: Span Inspection + +```typescript +function debugSpan(span: IReadableSpan | null): void { + if (!span) { + console.log('No span provided'); + return; + } + + console.group('Span Debug Info'); + console.log('Name:', span.name); + console.log('Kind:', span.kind); + console.log('Started:', span.startTime); + console.log('Ended:', span.ended); + console.log('Duration:', span.duration); + + const context = span.spanContext(); + console.log('Trace ID:', context.traceId); + console.log('Span ID:', context.spanId); + console.log('Trace Flags:', context.traceFlags); + + console.log('Attributes:', span.attributes); + console.log('Status:', span.status); + console.log('Is Recording:', span.isRecording()); + + console.groupEnd(); +} + +// Usage +const span = tracer.startSpan('debug-me'); +span?.setAttribute('debug', true); +debugSpan(span); +span?.end(); +``` + +## See Also + +- [Main README](./README.md) - OpenTelemetry compatibility overview +- [OTel API Documentation](./otelApi.md) - Main API reference +- [Trace API Documentation](./traceApi.md) - Tracer and span management +- [withSpan Helper](./withSpan.md) - Context management helper +- [useSpan Helper](./useSpan.md) - Scope-aware context helper + +--- + +These examples demonstrate real-world usage patterns for OpenTelemetry in Application Insights. Adapt them to your specific needs and follow the best practices outlined in each pattern. diff --git a/docs/OTel/otelApi.md b/docs/OTel/otelApi.md new file mode 100644 index 000000000..794d40524 --- /dev/null +++ b/docs/OTel/otelApi.md @@ -0,0 +1,511 @@ +# OTel API Reference + +## Overview + +The [`IOTelApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelApi.html) interface is the main entry point for OpenTelemetry functionality in the Application Insights JavaScript SDK. It provides access to tracers, the trace API, and configuration settings. This API closely follows the OpenTelemetry specification while integrating seamlessly with Application Insights. + +## Table of Contents + +- [Getting the OTel API](#getting-the-otel-api) +- [Interface Definition](#interface-definition) +- [Properties](#properties) +- [Creating an OTel API Instance](#creating-an-otel-api-instance) +- [Usage Examples](#usage-examples) +- [Best Practices](#best-practices) + +## Getting the OTel API + +### From AISKU (Web SDK) + +```typescript +import { ApplicationInsights } from '@microsoft/applicationinsights-web'; + +const appInsights = new ApplicationInsights({ + config: { + connectionString: 'YOUR_CONNECTION_STRING' + } +}); +appInsights.loadAppInsights(); + +// Get the OpenTelemetry API +const otelApi = appInsights.otelApi; +``` + +**Note:** The `otelApi` and `trace` properties are only available on the AISKU (`appInsights`) instance, not on `AppInsightsCore`. + +## Interface Definition + +```typescript +export interface IOTelApi extends IOTelTracerProvider { + /** + * The configuration object that contains all OpenTelemetry-specific settings. + */ + cfg: IOTelConfig; + + /** + * The current ITraceHost instance for this IOTelApi instance. + */ + host: ITraceHost; + + /** + * The current ITraceApi instance for this IOTelApi instance. + */ + trace: ITraceApi; +} +``` + +### Extended from [`IOTelTracerProvider`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracerProvider.html) + +Since [`IOTelApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelApi.html) extends [`IOTelTracerProvider`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracerProvider.html), it also provides: + +```typescript +/** + * Get a tracer for a specific instrumentation scope + * @param name - The name of the tracer/instrumentation library + * @param version - Optional version of the instrumentation library + * @param options - Optional tracer configuration options + * @returns A tracer instance + */ +getTracer(name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer; +``` + +## Properties + +### cfg: IOTelConfig + +The configuration object containing all OpenTelemetry-specific settings. + +**Type:** [`IOTelConfig`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelConfig.html) + +**Description:** Provides access to tracing configuration, error handlers, and other OpenTelemetry options. Changes to this configuration after initialization may not take effect until the next telemetry operation. + +**Example:** +```typescript +const otelApi = appInsights.otelApi; + +// Access trace configuration +if (otelApi.cfg.traceCfg) { + console.log('Tracing enabled:', !otelApi.cfg.traceCfg.suppressTracing); +} + +// Check for error handlers +if (otelApi.cfg.errorHandlers) { + console.log('Custom error handlers configured'); +} +``` + +### host: ITraceHost + +The trace host instance that manages span lifecycle and active span context. + +**Type:** [`ITraceHost`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceHost.html) + +**Description:** This is effectively the OpenTelemetry ContextAPI without the static methods. It provides methods for managing the active span context and creating spans. + +**Key Methods:** +- `startSpan(name, options?, context?)` - Create a new span +- `activeSpan()` - Get the current active span +- `setActiveSpan(span)` - Set a span as the active span +- `getTraceCtx()` - Get the current trace context + +**Example:** +```typescript +const otelApi = appInsights.otelApi; + +// Get the current active span +const activeSpan = otelApi.host.getActiveSpan(); +if (activeSpan) { + console.log('Active span:', activeSpan.name); + console.log('Trace ID:', activeSpan.spanContext().traceId); +} + +// Create a span directly via host +const span = otelApi.host.startSpan('operation-name', { + kind: OTelSpanKind.INTERNAL, + attributes: { 'custom.attribute': 'value' } +}); +``` + +### trace: ITraceApi + +The trace API providing utilities for tracer and span management. + +**Type:** [`ITraceApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceApi.html) + +**Description:** Provides the OpenTelemetry TraceAPI interface without static methods. This is the recommended way to get tracers and manage span contexts. + +**Key Methods:** +- `getTracer(name, version?, options?)` - Get or create a tracer +- `getActiveSpan()` - Get the current active span +- `setActiveSpan(span)` - Set the active span +- `wrapSpanContext(spanContext)` - Create a non-recording span from context +- `isSpanContextValid(spanContext)` - Validate a span context + +**Example:** +```typescript +const otelApi = appInsights.otelApi; + +// Get a tracer via trace API +const tracer = otelApi.trace.getTracer('my-service'); + +// Check for active span +const activeSpan = otelApi.trace.getActiveSpan(); +if (activeSpan) { + console.log('Current operation:', activeSpan.name); +} + +// Validate a span context +const isValid = otelApi.trace.isSpanContextValid(spanContext); +``` + +## Creating an OTel API Instance + +Typically, you don't create `IOTelApi` instances directly. They are created and managed by the Application Insights core. However, for advanced scenarios, you can use the `createOTelApi` function: + +### createOTelApi Function + +```typescript +function createOTelApi(otelApiCtx: IOTelApiCtx): IOTelApi; +``` + +**Parameters:** +- `otelApiCtx: IOTelApiCtx` - The context containing the host instance + +**Returns:** A new [`IOTelApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelApi.html) instance + +**Example:** +```typescript +import { createOTelApi } from '@microsoft/applicationinsights-core-js'; + +// Advanced: Create a custom OTel API instance +const otelApiCtx = { + host: appInsightsCore +}; + +const customOtelApi = createOTelApi(otelApiCtx); +``` + +### IOTelApiCtx Interface + +```typescript +export interface IOTelApiCtx { + /** + * The host instance (AppInsightsCore) that provides tracing capabilities + */ + host: ITraceHost; +} +``` + +## Usage Examples + +### Example 1: Basic API Access + +```typescript +import { ApplicationInsights } from '@microsoft/applicationinsights-web'; + +const appInsights = new ApplicationInsights({ + config: { + connectionString: 'YOUR_CONNECTION_STRING', + traceCfg: { + suppressTracing: false + } + } +}); +appInsights.loadAppInsights(); + +// Get the OTel API +const otelApi = appInsights.otelApi; + +// Access different parts of the API +console.log('Has trace API:', !!otelApi.trace); +console.log('Has host:', !!otelApi.host); +console.log('Configuration available:', !!otelApi.cfg); +``` + +### Example 2: Getting Tracers + +```typescript +const otelApi = appInsights.otelApi; + +// Get a tracer directly from the API +const tracer1 = otelApi.getTracer('service-a'); + +// Or get it via the trace property (recommended) +const tracer2 = otelApi.trace.getTracer('service-b'); + +// Both approaches work and return cached instances +const tracer3 = otelApi.trace.getTracer('service-a'); +console.log(tracer1 === tracer3); // true - same cached instance +``` + +### Example 3: Working with Active Spans + +```typescript +const otelApi = appInsights.otelApi; +const tracer = otelApi.trace.getTracer('my-app'); + +// Create and set an active span +const span = tracer.startSpan('operation'); +otelApi.trace.setActiveSpan(span); + +// Later, get the active span +const activeSpan = otelApi.trace.getActiveSpan(); +if (activeSpan) { + activeSpan.setAttribute('additional.info', 'added later'); +} + +// Clear the active span +otelApi.trace.setActiveSpan(null); +span.end(); +``` + +### Example 4: Span Context Utilities + +```typescript +const otelApi = appInsights.otelApi; + +// Check if a span context is valid +const spanContext = { + traceId: '00000000000000000000000000000001', + spanId: '0000000000000001', + traceFlags: 1 +}; + +if (otelApi.trace.isSpanContextValid(spanContext)) { + // Wrap the context in a non-recording span + const wrappedSpan = otelApi.trace.wrapSpanContext(spanContext); + + // Use the wrapped span (e.g., for context propagation) + otelApi.trace.setActiveSpan(wrappedSpan); + + // ... perform operations that should have this context + + otelApi.trace.setActiveSpan(null); +} +``` + +### Example 5: Multiple Service Components + +```typescript +const otelApi = appInsights.otelApi; + +// Define different tracers for different parts of your application +const tracers = { + userService: otelApi.trace.getTracer('user-service'), + paymentService: otelApi.trace.getTracer('payment-service'), + notificationService: otelApi.trace.getTracer('notification-service') +}; + +// Use the appropriate tracer for each operation +function authenticateUser(userId: string) { + const span = tracers.userService.startSpan('authenticate'); + span.setAttribute('user.id', userId); + // ... authentication logic + span.end(); +} + +function processPayment(amount: number) { + const span = tracers.paymentService.startSpan('process-payment'); + span.setAttribute('payment.amount', amount); + // ... payment logic + span.end(); +} + +function sendNotification(type: string) { + const span = tracers.notificationService.startSpan('send-notification'); + span.setAttribute('notification.type', type); + // ... notification logic + span.end(); +} +``` + +### Example 6: Accessing Configuration + +```typescript +const otelApi = appInsights.otelApi; + +// Check if tracing is enabled +function isTracingEnabled(): boolean { + return otelApi.cfg?.traceCfg?.suppressTracing !== true; +} + +// Conditionally create spans based on configuration +function createSpanIfEnabled(name: string): IReadableSpan | null { + if (!isTracingEnabled()) { + return null; + } + + const tracer = otelApi.trace.getTracer('my-service'); + return tracer.startSpan(name); +} + +// Use the conditional span creation +const span = createSpanIfEnabled('optional-trace'); +if (span) { + span.setAttribute('traced', true); + // ... do work + span.end(); +} +``` + +### Example 7: Integration with Host Methods + +```typescript +const otelApi = appInsights.otelApi; + +// Use host methods directly for lower-level control +const span = otelApi.host.startSpan('low-level-operation', { + kind: OTelSpanKind.INTERNAL, + attributes: { + 'operation.type': 'direct-host-call' + } +}); + +// Set it as active using the host +const scope = otelApi.host.setActiveSpan(span); + +try { + // Perform work with span active + console.log('Current trace context:', otelApi.host.getTraceCtx()); + + // Create child span (automatically uses active span as parent) + const childSpan = otelApi.host.startSpan('child-operation'); + childSpan?.end(); + +} finally { + // Restore previous context + scope?.restore(); + span.end(); +} +``` + +## Best Practices + +### 1. Prefer Direct startSpan for Most Scenarios + +**Recommended approach:** +```typescript +// Direct startSpan from AISKU - simplest for most use cases +appInsights.startSpan('operation-name', (span) => { + span.setAttribute('key', 'value'); + // operation code +}); +``` + +**Use getTracer for service organization:** +```typescript +// Only needed when organizing multiple services/components +const tracer = otelApi.trace.getTracer('my-service'); +tracer.startActiveSpan('operation', (span) => { + // ... +}); +``` + +### 2. Use the Trace API for Tracer Access + +**Recommended:** +```typescript +const tracer = otelApi.trace.getTracer('my-service'); +``` + +**Also Valid:** +```typescript +const tracer = otelApi.getTracer('my-service'); +``` + +Both work, but using `otelApi.trace.getTracer()` is more explicit and follows OpenTelemetry conventions. + +### 3. Cache the OTel API Reference + +```typescript +// Good - cache the reference +const otelApi = appInsights.otelApi; +const tracer = otelApi.trace.getTracer('my-service'); + +// Less efficient - repeated property access +appInsights.otelApi.trace.getTracer('my-service'); +``` + +### 4. Check for Null/Undefined + +While rare, always check for null when working with spans: + +```typescript +const span = tracer.startSpan('operation'); +if (span) { + // Safe to use span + span.setAttribute('key', 'value'); + span.end(); +} +``` + +### 5. Organize Tracers by Service/Component + +```typescript +// Good - clear organization +class UserService { + private tracer = otelApi.trace.getTracer('user-service'); + + authenticate(userId: string) { + const span = this.tracer.startSpan('authenticate'); + // ... + } +} + +class PaymentService { + private tracer = otelApi.trace.getTracer('payment-service'); + + processPayment(amount: number) { + const span = this.tracer.startSpan('process-payment'); + // ... + } +} +``` + +### 5. Use Configuration Safely + +```typescript +// Safe configuration access with fallbacks +function getTraceCfg(otelApi: IOTelApi): ITraceCfg | null { + return otelApi?.cfg?.traceCfg || null; +} + +// Check before using +const traceCfg = getTraceCfg(otelApi); +if (traceCfg && !traceCfg.suppressTracing) { + // Create spans +} +``` + +### 6. Prefer Trace API Methods Over Direct Host Access + +**Recommended:** +```typescript +const activeSpan = otelApi.trace.getActiveSpan(); +otelApi.trace.setActiveSpan(span); +``` + +**Direct (lower level):** +```typescript +const activeSpan = otelApi.host.getActiveSpan(); +otelApi.host.setActiveSpan(span); +``` + +The trace API methods provide better abstraction and follow OpenTelemetry conventions. + +## See Also + +- [Trace API Documentation](./traceApi.md) - Detailed tracer and span management +- [withSpan Helper](./withSpan.md) - Executing code with active span context +- [useSpan Helper](./useSpan.md) - Executing code with span scope parameter +- [Examples Guide](./examples.md) - Comprehensive usage examples +- [Main README](./README.md) - OpenTelemetry compatibility overview + +## Related Interfaces + +- [`IOTelConfig`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelConfig.html) - OpenTelemetry configuration settings +- [`ITraceHost`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceHost.html) - Host interface for span management +- [`ITraceApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceApi.html) - Trace API interface +- [`IOTelTracer`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracer.html) - Tracer interface for creating spans +- [`IOTelTracerProvider`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracerProvider.html) - Provider interface for getting tracers +- [`IOTelTracerOptions`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracerOptions.html) - Options for tracer creation diff --git a/docs/OTel/traceApi.md b/docs/OTel/traceApi.md new file mode 100644 index 000000000..3facc46f1 --- /dev/null +++ b/docs/OTel/traceApi.md @@ -0,0 +1,798 @@ +# Trace API Reference + +## Overview + +The [`ITraceApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceApi.html) interface provides the OpenTelemetry Trace API functionality for managing tracers and span contexts. It serves as the primary interface for creating tracers and working with span context utilities. This API closely follows the OpenTelemetry specification while integrating seamlessly with Application Insights. + +## Table of Contents + +- [Getting the Trace API](#getting-the-trace-api) +- [Interface Definition](#interface-definition) +- [Methods](#methods) + - [getTracer](#gettracer) + - [getActiveSpan](#getactivespan) + - [setActiveSpan](#setactivespan) + - [wrapSpanContext](#wrapspancontext) + - [isSpanContextValid](#isspancontextvalid) +- [Tracer Interface](#tracer-interface) +- [Usage Examples](#usage-examples) +- [Best Practices](#best-practices) + +## Getting the Trace API + +```typescript +import { ApplicationInsights } from '@microsoft/applicationinsights-web'; + +const appInsights = new ApplicationInsights({ + config: { + connectionString: 'YOUR_CONNECTION_STRING' + } +}); +appInsights.loadAppInsights(); + +// Get the Trace API +const otelApi = appInsights.otelApi; +const trace = otelApi.trace; + +// Or directly +const trace = appInsights.trace; +``` + +## Interface Definition + +```typescript +export interface ITraceApi { + /** + * Returns a Tracer, creating one if one with the given name and version + * if one has not already been created. + */ + getTracer(name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer; + + /** + * Wrap the given IDistributedTraceContext in a new non-recording IReadableSpan + */ + wrapSpanContext(spanContext: IDistributedTraceContext): IReadableSpan; + + /** + * Returns true if this IDistributedTraceContext is valid. + */ + isSpanContextValid(spanContext: IDistributedTraceContext): boolean; + + /** + * Gets the span from the current context, if one exists. + */ + getActiveSpan(): IReadableSpan | undefined | null; + + /** + * Set or clear the current active span. + */ + setActiveSpan(span: IReadableSpan | undefined | null): ISpanScope | undefined | null; +} +``` + +## Methods + +### getTracer + +Returns a tracer instance, creating one if necessary. Tracers are cached by name@version combination. + +```typescript +getTracer(name: string, version?: string, options?: IOTelTracerOptions): IOTelTracer; +``` + +**Parameters:** +- `name: string` - The name of the tracer or instrumentation library (required) +- `version?: string` - The version of the tracer or instrumentation library (optional) +- `options?: IOTelTracerOptions` - Additional configuration options (optional) + +**Returns:** [`IOTelTracer`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracer.html) - A tracer instance for creating spans + +**Caching Behavior:** +- Tracers are cached by `name@version` combination +- Multiple calls with same name/version return the same cached instance +- Different versions of the same name create separate tracers + +**Example:** +```typescript +const trace = otelApi.trace; + +// Get a tracer with just a name +const tracer1 = trace.getTracer('my-service'); + +// Get a tracer with name and version (optional) +const tracer2 = trace.getTracer('user-service', '2.1.0'); + +// Get a tracer with options +const tracer3 = trace.getTracer('payment-service', '1.5.0', { + schemaUrl: 'https://example.com/schema/v1' +}); + +// Subsequent call returns cached instance +const tracer4 = trace.getTracer('user-service', '2.1.0'); +console.log(tracer2 === tracer4); // true +``` + +### getActiveSpan + +Gets the currently active span from the current context, if one exists. + +```typescript +getActiveSpan(): IReadableSpan | undefined | null; +``` + +**Parameters:** None + +**Returns:** +- [`IReadableSpan`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IReadableSpan.html) - The currently active span +- `undefined | null` - If no span is currently active + +**Example:** +```typescript +const trace = otelApi.trace; + +// Check if there's an active span +const activeSpan = trace.getActiveSpan(); +if (activeSpan) { + console.log('Active span:', activeSpan.name); + console.log('Trace ID:', activeSpan.spanContext().traceId); + console.log('Span ID:', activeSpan.spanContext().spanId); + + // Add attributes to the active span + activeSpan.setAttribute('additional.context', 'added to active span'); +} else { + console.log('No active span'); +} +``` + +**Use Cases:** +- Check if code is executing within a span context +- Add attributes to the current span from deeply nested code +- Retrieve trace context for logging or propagation + +### setActiveSpan + +Sets a span as the active span in the current context, or clears the active span. + +```typescript +setActiveSpan(span: IReadableSpan | undefined | null): ISpanScope | undefined | null; +``` + +**Parameters:** +- `span: IReadableSpan | undefined | null` - The span to set as active, or null/undefined to clear + +**Returns:** +- [`ISpanScope`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ISpanScope.html) - A scope object that can be used to restore the previous active span +- `undefined | null` - If there is no defined host + +**Important:** Always restore the previous context by calling `scope.restore()` when done. + +**Example:** +```typescript +const trace = otelApi.trace; +const tracer = trace.getTracer('my-service'); + +// Create a span +const span = tracer.startSpan('operation'); + +// Set it as active +const scope = trace.setActiveSpan(span); + +try { + // Span is now active - child spans will use it as parent + const childSpan = tracer.startSpan('child-operation'); + childSpan?.end(); +} finally { + // Always restore previous context + scope?.restore(); + span.end(); +} + +// Clear the active span +trace.setActiveSpan(null); +``` + +**Advanced Example with Nested Scopes:** +```typescript +const trace = otelApi.trace; +const tracer = trace.getTracer('my-service'); + +const span1 = tracer.startSpan('operation-1'); +const scope1 = trace.setActiveSpan(span1); + +try { + // span1 is active + const span2 = tracer.startSpan('operation-2'); + const scope2 = trace.setActiveSpan(span2); + + try { + // span2 is active + console.log('Active:', trace.getActiveSpan()?.name); // 'operation-2' + } finally { + scope2?.restore(); // Restore to span1 + span2.end(); + } + + console.log('Active:', trace.getActiveSpan()?.name); // 'operation-1' +} finally { + scope1?.restore(); // Restore to previous (or no active span) + span1.end(); +} +``` + +### wrapSpanContext + +Wraps a distributed trace context in a new non-recording span. This is useful for context propagation without creating telemetry. + +```typescript +wrapSpanContext(spanContext: IDistributedTraceContext): IReadableSpan; +``` + +**Parameters:** +- `spanContext: IDistributedTraceContext` - The span context to wrap + +**Returns:** [`IReadableSpan`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IReadableSpan.html) - A non-recording span with the provided context + +**Key Characteristics:** +- Creates a span that doesn't record telemetry +- Preserves trace context (trace ID, span ID, trace flags) +- Useful for propagating existing trace context +- Can be used as parent for new spans + +**Example:** +```typescript +const trace = otelApi.trace; + +// Received trace context from external source (e.g., HTTP header) +const incomingContext = { + traceId: '00000000000000000000000000000001', + spanId: '0000000000000001', + traceFlags: 1 +}; + +// Wrap the context in a non-recording span +const wrappedSpan = trace.wrapSpanContext(incomingContext); + +// Set as active to propagate context +const scope = trace.setActiveSpan(wrappedSpan); + +try { + // Create child spans that inherit the external trace context + const tracer = trace.getTracer('my-service'); + const childSpan = tracer.startSpan('process-request'); + + // childSpan will have the same traceId and wrappedSpan's spanId as parent + console.log('Trace ID:', childSpan?.spanContext().traceId); + + childSpan?.end(); +} finally { + scope?.restore(); +} +``` + +**Distributed Tracing Example:** +```typescript +// Service A: Extract context from span +const span = tracer.startSpan('api-call'); +const traceContext = span.spanContext(); + +// Send to Service B (e.g., via HTTP headers) +const headers = { + 'traceparent': `00-${traceContext.traceId}-${traceContext.spanId}-01` +}; + +// Service B: Receive and wrap context +function handleIncomingRequest(headers: Record) { + // Parse traceparent header + const traceContext = parseTraceparent(headers['traceparent']); + + // Wrap in a non-recording span + const wrappedSpan = trace.wrapSpanContext(traceContext); + + // Use as parent for new spans + const scope = trace.setActiveSpan(wrappedSpan); + try { + const childSpan = tracer.startSpan('process-request'); + // childSpan maintains the distributed trace + childSpan?.end(); + } finally { + scope?.restore(); + } +} +``` + +### isSpanContextValid + +Validates whether a span context has valid trace ID and span ID. + +```typescript +isSpanContextValid(spanContext: IDistributedTraceContext): boolean; +``` + +**Parameters:** +- `spanContext: IDistributedTraceContext` - The span context to validate + +**Returns:** `boolean` - `true` if the context is valid, `false` otherwise + +**Validation Rules:** +- Trace ID must be valid (non-zero, proper length) +- Span ID must be valid (non-zero, proper length) +- Both must exist + +**Example:** +```typescript +const trace = otelApi.trace; + +// Valid context +const validContext = { + traceId: '00000000000000000000000000000001', + spanId: '0000000000000001', + traceFlags: 1 +}; + +console.log(trace.isSpanContextValid(validContext)); // true + +// Invalid context (zero trace ID) +const invalidContext = { + traceId: '00000000000000000000000000000000', + spanId: '0000000000000001', + traceFlags: 1 +}; + +console.log(trace.isSpanContextValid(invalidContext)); // false + +// Practical usage +function propagateContext(context: IDistributedTraceContext) { + if (trace.isSpanContextValid(context)) { + const wrappedSpan = trace.wrapSpanContext(context); + return trace.setActiveSpan(wrappedSpan); + } else { + console.warn('Invalid trace context, not propagating'); + return null; + } +} +``` + +## Tracer Interface + +The [`IOTelTracer`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracer.html) interface returned by `getTracer()` provides methods for creating spans. + +### IOTelTracer Methods + +#### startSpan + +Creates a new span without setting it as active. + +```typescript +startSpan(name: string, options?: IOTelSpanOptions): IReadableSpan | null; +``` + +**Parameters:** +- `name: string` - The span name +- `options?: IOTelSpanOptions` - Configuration options + +**Example:** +```typescript +const tracer = trace.getTracer('my-service'); + +const span = tracer.startSpan('database-query', { + kind: OTelSpanKind.CLIENT, + attributes: { + 'db.system': 'postgresql', + 'db.operation': 'SELECT' + } +}); + +if (span) { + // Use span + span.setAttribute('db.table', 'users'); + // ... perform operation + span.end(); +} +``` + +#### startActiveSpan + +Creates a span, sets it as active, and executes a callback function. + +```typescript +startActiveSpan unknown>( + name: string, + fn: F +): ReturnType; + +startActiveSpan unknown>( + name: string, + options: IOTelSpanOptions, + fn: F +): ReturnType; +``` + +**Parameters:** +- `name: string` - The span name +- `options?: IOTelSpanOptions` - Configuration options (optional) +- `fn: (span: IReadableSpan) => any` - The callback function + +**Returns:** The result of the callback function + +**Example:** +```typescript +const tracer = trace.getTracer('my-service'); + +// Simple form +const result = tracer.startActiveSpan('operation', (span) => { + span.setAttribute('key', 'value'); + return processData(); + // Span automatically ended when function returns +}); + +// With options +const result2 = tracer.startActiveSpan('api-call', + { + kind: OTelSpanKind.CLIENT, + attributes: { 'http.method': 'GET' } + }, + async (span) => { + const response = await fetch('/api/data'); + span.setAttribute('http.status_code', response.status); + return response.json(); + } +); +``` + +## Usage Examples + +### Example 1: Basic Tracer Usage + +```typescript +const trace = otelApi.trace; + +// Get a tracer +const tracer = trace.getTracer('user-service'); + +// Create a simple span +const span = tracer.startSpan('authenticate-user'); +if (span) { + span.setAttribute('user.id', '12345'); + + try { + // Perform authentication + const user = await authenticateUser(); + span.setAttribute('auth.success', true); + span.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + } finally { + span.end(); + } +} +``` + +### Example 2: Multiple Tracers + +```typescript +const trace = otelApi.trace; + +// Create tracers for different services +const userTracer = trace.getTracer('user-service'); +const paymentTracer = trace.getTracer('payment-service'); +const notificationTracer = trace.getTracer('notification-service'); + +// Use appropriate tracer for each operation +function handleCheckout(userId: string, amount: number) { + // User service operation + const userSpan = userTracer.startSpan('validate-user'); + userSpan?.setAttribute('user.id', userId); + userSpan?.end(); + + // Payment service operation + const paymentSpan = paymentTracer.startSpan('process-payment'); + paymentSpan?.setAttribute('payment.amount', amount); + paymentSpan?.end(); + + // Notification service operation + const notifySpan = notificationTracer.startSpan('send-receipt'); + notifySpan?.setAttribute('notification.type', 'email'); + notifySpan?.end(); +} +``` + +### Example 3: Active Span Management + +```typescript +const trace = otelApi.trace; +const tracer = trace.getTracer('my-app'); + +// Create parent span +const parentSpan = tracer.startSpan('parent-operation'); +const scope = trace.setActiveSpan(parentSpan); + +try { + // Child spans automatically use parent + const child1 = tracer.startSpan('child-1'); + child1?.setAttribute('order', 1); + child1?.end(); + + const child2 = tracer.startSpan('child-2'); + child2?.setAttribute('order', 2); + child2?.end(); + + // Check active span + const active = trace.getActiveSpan(); + console.log('Active span:', active?.name); // 'parent-operation' + +} finally { + scope?.restore(); + parentSpan?.end(); +} +``` + +### Example 4: Context Propagation + +```typescript +const trace = otelApi.trace; + +// Extract context from current span +function getCurrentTraceContext(): IDistributedTraceContext | null { + const activeSpan = trace.getActiveSpan(); + return activeSpan ? activeSpan.spanContext() : null; +} + +// Propagate context to async operation +async function performAsyncWork(context: IDistributedTraceContext | null) { + if (context && trace.isSpanContextValid(context)) { + // Wrap and set as active + const wrappedSpan = trace.wrapSpanContext(context); + const scope = trace.setActiveSpan(wrappedSpan); + + try { + // Create child span with inherited context + const tracer = trace.getTracer('async-worker'); + const span = tracer.startSpan('async-operation'); + + // Work maintains trace context + await doAsyncWork(); + + span?.end(); + } finally { + scope?.restore(); + } + } +} + +// Usage +const context = getCurrentTraceContext(); +setTimeout(() => performAsyncWork(context), 1000); +``` + +### Example 5: Using startActiveSpan + +```typescript +const trace = otelApi.trace; +const tracer = trace.getTracer('my-service'); + +// Automatic span lifecycle management +async function processOrder(orderId: string) { + return tracer.startActiveSpan('process-order', async (span) => { + span.setAttribute('order.id', orderId); + + // All nested operations inherit this span context + const items = await fetchOrderItems(orderId); + span.setAttribute('order.items', items.length); + + const total = await calculateTotal(items); + span.setAttribute('order.total', total); + + return { orderId, items, total }; + // Span automatically ended when function returns + }); +} + +// Nested startActiveSpan calls +function nestedOperations() { + return tracer.startActiveSpan('parent', (parentSpan) => { + parentSpan.setAttribute('level', 1); + + return tracer.startActiveSpan('child', (childSpan) => { + childSpan.setAttribute('level', 2); + + // childSpan automatically has parentSpan as parent + console.log('Parent trace ID:', parentSpan.spanContext().traceId); + console.log('Child trace ID:', childSpan.spanContext().traceId); + // Both have the same trace ID + + return 'result'; + }); + }); +} +``` + +### Example 6: Conditional Tracing + +```typescript +const trace = otelApi.trace; +const tracer = trace.getTracer('my-service'); + +function conditionalTrace(operation: string, enabled: boolean) { + if (!enabled) { + // Perform operation without tracing + return performOperation(operation); + } + + // Trace the operation + return tracer.startActiveSpan(operation, (span) => { + span.setAttribute('traced', true); + return performOperation(operation); + }); +} + +// Check if currently being traced +function isCurrentlyTraced(): boolean { + return trace.getActiveSpan() !== null; +} + +// Conditionally add details +function addTraceDetails(key: string, value: any) { + const activeSpan = trace.getActiveSpan(); + if (activeSpan) { + activeSpan.setAttribute(key, value); + } +} + +// Usage +addTraceDetails('user.action', 'clicked-button'); +``` + +## Best Practices + +### 1. Prefer Direct startSpan from AISKU + +**Recommended approach:** +```typescript +// Direct startSpan from AISKU - simplest for most use cases +appInsights.startSpan('operation-name', (span) => { + span.setAttribute('key', 'value'); + // operation code +}); +``` + +**Use getTracer for service organization:** +```typescript +// Only needed when organizing multiple services/components +const tracer = otelApi.trace.getTracer('user-service'); +tracer.startActiveSpan('authenticate', (span) => { + // ... +}); +``` + +### 2. Cache Tracer Instances + +```typescript +// Good - cache tracers +class UserService { + private tracer = otelApi.trace.getTracer('user-service'); + + async authenticate(userId: string) { + return this.tracer.startActiveSpan('authenticate', async (span) => { + span.setAttribute('user.id', userId); + // ... + }); + } +} + +// Less efficient - create tracer each time +function authenticate(userId: string) { + const tracer = otelApi.trace.getTracer('user-service'); + // ... +} +``` + +### 3. Always Restore Context + +```typescript +// Good - always restore +const scope = trace.setActiveSpan(span); +try { + // ... operations +} finally { + scope?.restore(); + span.end(); +} + +// Bad - forgetting to restore can leak context +const scope = trace.setActiveSpan(span); +// ... operations +span.end(); // Forgot to restore! +``` + +### 4. Prefer startActiveSpan + +```typescript +// Recommended - automatic lifecycle +tracer.startActiveSpan('operation', (span) => { + span.setAttribute('key', 'value'); + return doWork(); +}); + +// Manual - requires careful management +const span = tracer.startSpan('operation'); +const scope = trace.setActiveSpan(span); +try { + span.setAttribute('key', 'value'); + return doWork(); +} finally { + scope?.restore(); + span.end(); +} +``` + +### 5. Validate External Context + +```typescript +// Good - validate before using +function handleExternalContext(context: any) { + if (trace.isSpanContextValid(context)) { + const span = trace.wrapSpanContext(context); + // Use span + } else { + console.warn('Invalid trace context received'); + } +} + +// Bad - assume context is valid +function handleExternalContext(context: any) { + const span = trace.wrapSpanContext(context); // May fail! +} +``` + +### 5. Use Meaningful Tracer Names + +```typescript +// Good - descriptive names +const authTracer = trace.getTracer('auth-service'); +const dbTracer = trace.getTracer('database-layer'); +const apiTracer = trace.getTracer('api-client'); + +// Less useful - generic names +const tracer1 = trace.getTracer('tracer'); +const tracer2 = trace.getTracer('app'); +``` + +### 6. Check for Active Span Before Adding Attributes + +```typescript +// Safe - check before using +function addContextInfo(key: string, value: any) { + const activeSpan = trace.getActiveSpan(); + if (activeSpan) { + activeSpan.setAttribute(key, value); + } +} + +// Works but creates unnecessary span +function addContextInfo(key: string, value: any) { + const span = tracer.startSpan('temp'); + span?.setAttribute(key, value); + span?.end(); // Creates telemetry unnecessarily +} +``` + +## See Also + +- [OTel API Documentation](./otelApi.md) - Main OpenTelemetry API interface +- [withSpan Helper](./withSpan.md) - Executing code with active span context +- [useSpan Helper](./useSpan.md) - Executing code with span scope parameter +- [Examples Guide](./examples.md) - Comprehensive usage examples +- [Main README](./README.md) - OpenTelemetry compatibility overview + +## Related Interfaces + +- [`ITraceApi`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceApi.html) - Trace API interface +- [`IOTelTracer`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelTracer.html) - Tracer interface for creating spans +- [`IReadableSpan`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IReadableSpan.html) - Span interface +- [`IOTelSpanOptions`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IOTelSpanOptions.html) - Options for span creation +- [`IDistributedTraceContext`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IDistributedTraceContext.html) - Distributed trace context +- [`ISpanScope`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ISpanScope.html) - Scope for restoring active span context diff --git a/docs/OTel/useSpan.md b/docs/OTel/useSpan.md new file mode 100644 index 000000000..0d45eef25 --- /dev/null +++ b/docs/OTel/useSpan.md @@ -0,0 +1,804 @@ +# useSpan Helper Function + +## Overview + +The `useSpan` helper function is similar to `withSpan` but provides the [`ISpanScope`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ISpanScope.html) object as the first parameter to your callback function. This allows you to manually manage span context restoration or access scope properties when needed. It automatically manages span activation and ensures proper context restoration after execution completes. + +## Table of Contents + +- [Function Signature](#function-signature) +- [Parameters](#parameters) +- [Return Value](#return-value) +- [Key Features](#key-features) +- [Comparison with withSpan](#comparison-with-withspan) +- [Usage Examples](#usage-examples) +- [Best Practices](#best-practices) +- [Common Patterns](#common-patterns) +- [Error Handling](#error-handling) + +## Function Signature + +```typescript +function useSpan | ISpanScope, scope: ISpanScope, ...args: A) => ReturnType>( + traceHost: T, + span: IReadableSpan, + fn: F, + thisArg?: ThisParameterType, + ..._args: A +): ReturnType; +``` + +## Parameters + +### traceHost: ITraceHost + +The trace host instance that manages span contexts. Typically, this is your `AppInsightsCore` instance or the AISKU instance. + +**Type:** [`ITraceHost`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceHost.html) + +**Example:** +```typescript +const core = new AppInsightsCore(); +// or +const appInsights = new ApplicationInsights({ ... }); +``` + +### span: IReadableSpan + +The span to set as the active span during the execution of the callback function. + +**Type:** [`IReadableSpan`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IReadableSpan.html) + +**Example:** +```typescript +const tracer = otelApi.trace.getTracer('my-service'); +const span = tracer.startSpan('operation-name'); +``` + +### fn: Function + +The callback function to execute with the span as the active context. The function receives the [`ISpanScope`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ISpanScope.html) as its first parameter, followed by any additional arguments. + +**Signature:** +```typescript +(this: ThisParameterType | ISpanScope, scope: ISpanScope, ...args: A) => ReturnType +``` + +### thisArg?: any (optional) + +The `this` context for the callback function. If not provided, the [`ISpanScope`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ISpanScope.html) instance is used as `this`. + +### ..._args: any[] (optional) + +Additional arguments to pass to the callback function (after the scope parameter). + +## Return Value + +Returns the result of executing the callback function. The return type matches the callback function's return type. + +**For synchronous functions:** +```typescript +const result: number = useSpan(core, span, (scope) => { + return 42; +}); +``` + +**For asynchronous functions:** +```typescript +const result: Promise = useSpan(core, span, async (scope) => { + return await fetchData(); +}); +``` + +## Key Features + +### 1. Scope Parameter Access + +The callback receives an `ISpanScope` object as its first parameter, providing access to: +- `restore()` - Method to manually restore the previous active span +- The trace host instance (depending on implementation) + +```typescript +useSpan(core, span, (scope) => { + // Access the scope + console.log('Scope available:', scope); + + // Can manually restore if needed + // scope.restore(); +}); +``` + +### 2. Automatic Span Context Management + +Sets the span as active during execution and restores the previous active span afterward (unless manually restored). + +```typescript +useSpan(core, span, (scope) => { + // span is active here + console.log(core.getActiveSpan() === span); // true +}); +// Previous active span is restored here +``` + +### 3. Manual Context Control + +Unlike `withSpan`, you can manually restore the context within your callback if needed. + +```typescript +useSpan(core, span, (scope) => { + // Do some work with span active + doWork(); + + // Manually restore early if needed + scope.restore(); + + // Continue with previous context + doMoreWork(); +}); +``` + +### 4. Exception Safety + +Ensures the previous active span is restored even if the callback throws an error. + +### 5. Async Support + +Automatically handles both synchronous and asynchronous callbacks, including Promise-based functions. + +## Comparison with withSpan + +The main difference between `useSpan` and `withSpan` is how they pass parameters to the callback function. + +### withSpan + +Does NOT pass the scope to the callback: + +```typescript +withSpan(core, span, (arg1, arg2) => { + // arg1 and arg2 are your custom arguments + // No access to scope + console.log(arg1, arg2); +}, null, 'value1', 'value2'); +``` + +### useSpan + +Passes the scope as the FIRST parameter: + +```typescript +useSpan(core, span, (scope, arg1, arg2) => { + // scope is the ISpanScope object + // arg1 and arg2 are your custom arguments + console.log(scope, arg1, arg2); + + // Can manually restore + scope.restore(); +}, null, 'value1', 'value2'); +``` + +**When to use which:** + +| Use `withSpan` | Use `useSpan` | +|----------------|---------------| +| Don't need scope access | Need to manually restore context | +| Simple callback execution | Need scope for advanced scenarios | +| Most common scenarios | Complex context management | +| Cleaner callback signature | Need explicit control | + +## Usage Examples + +### Example 1: Basic Usage + +```typescript +import { ApplicationInsights } from '@microsoft/applicationinsights-web'; +import { useSpan } from '@microsoft/applicationinsights-core-js'; + +const appInsights = new ApplicationInsights({ + config: { + connectionString: 'YOUR_CONNECTION_STRING' + } +}); +appInsights.loadAppInsights(); + +const tracer = appInsights.trace.getTracer('my-service'); + +// Create a span +const span = tracer.startSpan('operation'); + +// Execute code with span as active context +const result = useSpan(appInsights, span, (scope) => { + span.setAttribute('step', 'processing'); + + // Perform work - span is active during execution + const data = processData(); + + span.setAttribute('result', 'success'); + return data; +}); + +span.end(); +``` + +### Example 2: Manual Context Restoration + +```typescript +const span = tracer.startSpan('complex-operation'); + +useSpan(appInsights, span, (scope) => { + span.setAttribute('phase', 'with-context'); + + // Do work with span active + performWorkWithContext(); + + // Manually restore context early + scope.restore(); + + span.setAttribute('phase', 'without-context'); + + // Continue work with previous context + performWorkWithoutContext(); +}); + +span.end(); +``` + +### Example 3: Conditional Context Restoration + +```typescript +const span = tracer.startSpan('conditional-context'); + +useSpan(core, span, (scope, shouldRestoreEarly) => { + span.setAttribute('processing', true); + + doInitialWork(); + + if (shouldRestoreEarly) { + console.log('Restoring context early'); + scope.restore(); + } + + // If restored early, runs with previous context + // Otherwise, runs with span context + doAdditionalWork(); +}, null, true); // Pass shouldRestoreEarly = true + +span.end(); +``` + +### Example 4: Async Operations + +```typescript +const span = tracer.startSpan('async-operation'); + +const result = await useSpan(core, span, async (scope) => { + span.setAttribute('started', Date.now()); + + // Async work + const response = await fetch('/api/data'); + const data = await response.json(); + + span.setAttribute('completed', Date.now()); + span.setAttribute('items', data.length); + + return data; +}); + +span.end(); +console.log('Result:', result); +``` + +### Example 5: Accessing Scope Properties + +```typescript +const span = tracer.startSpan('scope-access'); + +useSpan(core, span, (scope) => { + // Access scope properties + console.log('Scope type:', typeof scope); + console.log('Has restore method:', typeof scope.restore === 'function'); + + // Perform work + span.setAttribute('scope.available', true); + + // Demonstrate manual restore + const shouldRestoreNow = checkCondition(); + if (shouldRestoreNow) { + scope.restore(); + } +}); + +span.end(); +``` + +### Example 6: Passing Additional Arguments + +```typescript +function processWithContext( + scope: ISpanScope, + userId: string, + action: string +): string { + console.log('Scope:', scope); + return `User ${userId} performed ${action}`; +} + +const span = tracer.startSpan('user-action'); + +// Pass additional arguments to callback +const result = useSpan( + core, + span, + processWithContext, + null, // thisArg + 'user123', // userId + 'checkout' // action +); + +span.end(); +console.log(result); // 'User user123 performed checkout' +``` + +### Example 7: Error Handling with Manual Restoration + +```typescript +const span = tracer.startSpan('error-handling'); + +try { + useSpan(core, span, (scope) => { + span.setAttribute('attempting', true); + + try { + performRiskyOperation(); + span.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + // Record error and restore context before handling + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + + // Restore context before error propagates + scope.restore(); + + throw error; + } + }); +} catch (error) { + // Context already restored + console.error('Operation failed:', error); +} finally { + span.end(); +} +``` + +### Example 8: Nested Spans with Manual Control + +```typescript +const parentSpan = tracer.startSpan('parent-operation'); + +useSpan(core, parentSpan, (parentScope) => { + parentSpan.setAttribute('level', 1); + + // Create child span + const childSpan = tracer.startSpan('child-operation'); + + useSpan(core, childSpan, (childScope) => { + childSpan.setAttribute('level', 2); + + // Access both scopes + console.log('Parent scope:', parentScope); + console.log('Child scope:', childScope); + + // Perform work + doNestedWork(); + + // Could manually restore child scope if needed + // childScope.restore(); + }); + + childSpan.end(); + + // Parent scope is still active here + console.log('Back to parent context'); +}); + +parentSpan.end(); +``` + +### Example 9: Complex Async Flow + +```typescript +const span = tracer.startSpan('complex-async'); + +await useSpan(core, span, async (scope) => { + span.setAttribute('stage', 'initial'); + + // Phase 1: With span context + await performPhase1(); + + // Conditionally restore based on async result + const shouldContinueWithContext = await checkContinuation(); + + if (!shouldContinueWithContext) { + scope.restore(); + span.setAttribute('context.restored.early', true); + } + + // Phase 2: Might be with or without context + await performPhase2(); + + span.setAttribute('stage', 'completed'); +}); + +span.end(); +``` + +### Example 10: Scope Inspection + +```typescript +const span = tracer.startSpan('scope-inspection'); + +useSpan(core, span, (scope) => { + // Inspect scope + console.log('Scope available:', !!scope); + console.log('Scope has restore:', 'restore' in scope); + + // Log current state + const currentSpan = core.getActiveSpan(); + console.log('Current span matches:', currentSpan === span); + + // Perform work + doWork(); + + // Demonstrate restoration + console.log('Before restore - active span:', core.getActiveSpan()?.name); + scope.restore(); + console.log('After restore - active span:', core.getActiveSpan()?.name); +}); + +span.end(); +``` + +## Best Practices + +### 1. Only Use useSpan When You Need the Scope + +```typescript +// Good - need scope for manual restore +useSpan(core, span, (scope) => { + doWork(); + if (needsEarlyRestore()) { + scope.restore(); + } + doMoreWork(); +}); + +// Better - use withSpan if scope not needed +withSpan(core, span, () => { + doWork(); + doMoreWork(); +}); +``` + +### 2. Be Careful with Manual Restoration + +```typescript +// Good - restore at the right time +useSpan(core, span, (scope) => { + doWorkNeedingContext(); + scope.restore(); + doWorkNotNeedingContext(); +}); + +// Bad - restoring too early +useSpan(core, span, (scope) => { + scope.restore(); // Too early! + doWorkNeedingContext(); // Context already gone +}); +``` + +### 3. Don't Restore Multiple Times + +```typescript +// Good - restore once +useSpan(core, span, (scope) => { + doWork(); + scope.restore(); +}); + +// Bad - multiple restores +useSpan(core, span, (scope) => { + scope.restore(); + // ... more code ... + scope.restore(); // Second restore - unexpected behavior! +}); +``` + +### 4. Always End Spans + +```typescript +// Good - span is ended +const span = tracer.startSpan('operation'); +try { + useSpan(core, span, (scope) => { + // Work + }); +} finally { + span.end(); +} + +// Bad - span is never ended +const span = tracer.startSpan('operation'); +useSpan(core, span, (scope) => { + // Work +}); // Forgot to end span! +``` + +### 5. Use with startSpan, Not startActiveSpan + +```typescript +// Good - use useSpan with startSpan +const span = tracer.startSpan('operation'); +useSpan(core, span, (scope) => { + // Work +}); +span.end(); + +// Redundant - startActiveSpan already manages context +tracer.startActiveSpan('operation', (span) => { + // No need for useSpan here +}); +``` + +### 6. Handle Async Carefully + +```typescript +// Good - proper async handling +const span = tracer.startSpan('async-op'); +try { + await useSpan(core, span, async (scope) => { + await doAsyncWork(); + }); +} finally { + span.end(); +} + +// Bad - not awaiting +const span = tracer.startSpan('async-op'); +useSpan(core, span, async (scope) => { + await doAsyncWork(); +}); // Not awaited! +span.end(); // Ends before async work completes +``` + +## Common Patterns + +### Pattern 1: Conditional Context Management + +```typescript +function executeWithOptionalContext( + span: IReadableSpan, + fn: () => T, + keepContextActive: boolean +): T { + return useSpan(core, span, (scope) => { + const result = fn(); + + if (!keepContextActive) { + scope.restore(); + } + + return result; + }); +} + +// Usage +executeWithOptionalContext(span, doWork, false); // Restores context +executeWithOptionalContext(span, doWork, true); // Keeps context +``` + +### Pattern 2: Scope-Aware Wrapper + +```typescript +function withManagedScope( + operationName: string, + fn: (scope: ISpanScope) => T +): T { + const span = tracer.startSpan(operationName); + try { + return useSpan(core, span, fn); + } finally { + span.end(); + } +} + +// Usage +withManagedScope('my-operation', (scope) => { + console.log('Have scope:', scope); + // Can manually restore if needed + return performWork(); +}); +``` + +### Pattern 3: Early Exit with Restoration + +```typescript +function processWithEarlyExit(data: any): any { + const span = tracer.startSpan('process'); + + return useSpan(core, span, (scope) => { + if (!validateData(data)) { + scope.restore(); + span.end(); + return null; // Early exit + } + + const result = processData(data); + span.end(); + return result; + }); +} +``` + +### Pattern 4: Scope Delegation + +```typescript +function delegateWithScope( + span: IReadableSpan, + operation: (restore: () => void) => T +): T { + return useSpan(core, span, (scope) => { + // Delegate restore capability to the operation + return operation(() => scope.restore()); + }); +} + +// Usage +delegateWithScope(span, (restore) => { + doWork(); + + if (shouldStop()) { + restore(); // Operation controls restoration + return null; + } + + return continueWork(); +}); +``` + +## Error Handling + +### Automatic Context Restoration on Error + +`useSpan` guarantees context restoration even when errors occur: + +```typescript +const previousSpan = core.getActiveSpan(); +const span = tracer.startSpan('operation'); + +try { + useSpan(core, span, (scope) => { + throw new Error('Operation failed'); + }); +} catch (error) { + // Context is restored to previousSpan + console.log(core.getActiveSpan() === previousSpan); // true +} finally { + span.end(); +} +``` + +### Manual Restoration Before Error + +```typescript +const span = tracer.startSpan('operation'); + +try { + useSpan(core, span, (scope) => { + try { + performRiskyOperation(); + } catch (error) { + // Restore context before handling error + scope.restore(); + + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + + throw error; + } + }); +} catch (error) { + console.error('Handled:', error); +} finally { + span.end(); +} +``` + +### Async Error Handling + +```typescript +const span = tracer.startSpan('async-operation'); + +try { + await useSpan(core, span, async (scope) => { + try { + const result = await fetch('/api/data'); + if (!result.ok) { + throw new Error(`HTTP ${result.status}`); + } + return result.json(); + } catch (error) { + // Can restore before re-throwing if needed + scope.restore(); + span.recordException(error); + throw error; + } + }); +} catch (error) { + console.error('Request failed:', error); +} finally { + span.end(); +} +``` + +### Scope State After Restoration + +```typescript +const span = tracer.startSpan('state-check'); + +useSpan(core, span, (scope) => { + console.log('Before restore:', core.getActiveSpan() === span); // true + + scope.restore(); + + console.log('After restore:', core.getActiveSpan() === span); // false + + // Calling restore again has no effect (already restored) + scope.restore(); +}); + +span.end(); +``` + +## ISpanScope Interface + +The `ISpanScope` interface provides methods and properties for managing span context: + +```typescript +interface ISpanScope { + /** + * Restore the previous active span context + */ + restore(): void; + + // Additional properties may be available depending on implementation +} +``` + +### Using the Scope + +```typescript +useSpan(core, span, (scope) => { + // Check if scope has restore method + if (typeof scope.restore === 'function') { + // Perform work + doWork(); + + // Restore when needed + scope.restore(); + } +}); +``` + +## See Also + +- [withSpan Helper](./withSpan.md) - Similar helper without scope parameter +- [Trace API Documentation](./traceApi.md) - Tracer and span management +- [OTel API Documentation](./otelApi.md) - Main OpenTelemetry API +- [Examples Guide](./examples.md) - Comprehensive usage examples +- [Main README](./README.md) - OpenTelemetry compatibility overview + +## Related Types + +- `ITraceHost` - Host interface for span management +- `IReadableSpan` - Span interface +- `ISpanScope` - Scope for restoring active span context +- `IOTelTracer` - Tracer interface for creating spans diff --git a/docs/OTel/withSpan.md b/docs/OTel/withSpan.md new file mode 100644 index 000000000..5601cb4e8 --- /dev/null +++ b/docs/OTel/withSpan.md @@ -0,0 +1,725 @@ +# withSpan Helper Function + +## Overview + +The `withSpan` helper function provides a convenient way to execute code within the context of an active span. It automatically manages span activation and restoration, ensuring that the previous active span context is properly restored after execution completes, even if an error occurs. + +## Table of Contents + +- [Function Signature](#function-signature) +- [Parameters](#parameters) +- [Return Value](#return-value) +- [Key Features](#key-features) +- [Usage Examples](#usage-examples) +- [Comparison with useSpan](#comparison-with-usespan) +- [Best Practices](#best-practices) +- [Common Patterns](#common-patterns) +- [Error Handling](#error-handling) + +## Function Signature + +```typescript +function withSpan | ISpanScope, ...args: A) => ReturnType>( + traceHost: T, + span: IReadableSpan, + fn: F, + thisArg?: ThisParameterType, + ..._args: A +): ReturnType; +``` + +## Parameters + +### traceHost: ITraceHost + +The trace host instance that manages span contexts. Typically, this is your `AppInsightsCore` instance or the AISKU instance. + +**Type:** [`ITraceHost`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ITraceHost.html) + +**Example:** +```typescript +const core = new AppInsightsCore(); +// or +const appInsights = new ApplicationInsights({ ... }); +``` + +### span: IReadableSpan + +The span to set as the active span during the execution of the callback function. + +**Type:** [`IReadableSpan`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/IReadableSpan.html) + +**Example:** +```typescript +const tracer = otelApi.trace.getTracer('my-service'); +const span = tracer.startSpan('operation-name'); +``` + +### fn: Function + +The callback function to execute with the span as the active context. The function receives any additional arguments passed to `withSpan`. + +**Signature:** +```typescript +(this: ThisParameterType | ISpanScope, ...args: A) => ReturnType +``` + +### thisArg?: any (optional) + +The `this` context for the callback function. If not provided, the [`ISpanScope`](https://microsoft.github.io/ApplicationInsights-JS/webSdk/applicationinsights-core-js/interfaces/ISpanScope.html) instance is used as `this`. + +### ..._args: any[] (optional) + +Additional arguments to pass to the callback function. + +## Return Value + +Returns the result of executing the callback function. The return type matches the callback function's return type. + +**For synchronous functions:** +```typescript +const result: number = withSpan(core, span, () => { + return 42; +}); +``` + +**For asynchronous functions:** +```typescript +const result: Promise = withSpan(core, span, async () => { + return await fetchData(); +}); +``` + +## Key Features + +### 1. Automatic Span Context Management + +Sets the span as active during execution and restores the previous active span afterward. + +```typescript +withSpan(core, span, () => { + // span is active here + console.log(core.getActiveSpan() === span); // true +}); +// Previous active span is restored here +``` + +### 2. Context Restoration + +Ensures the previous active span is restored even if the callback throws an error. + +```typescript +const previousSpan = core.getActiveSpan(); + +try { + withSpan(core, span, () => { + throw new Error('Something went wrong'); + }); +} catch (error) { + // Previous span is still restored + console.log(core.getActiveSpan() === previousSpan); // true +} +``` + +### 3. Exception Safety + +Uses try-finally blocks internally to guarantee context restoration. + +### 4. Async Support + +Automatically handles both synchronous and asynchronous callbacks, including Promise-based functions. + +```typescript +// Async callback +await withSpan(core, span, async () => { + await doAsyncWork(); + // Context restored after promise settles +}); +``` + +### 5. Nested Span Support + +Handles complex span hierarchies automatically through proper context stacking. + +```typescript +withSpan(core, parentSpan, () => { + // parentSpan is active + + withSpan(core, childSpan, () => { + // childSpan is active + }); + + // parentSpan is active again +}); +``` + +## Usage Examples + +### Example 1: Basic Usage + +```typescript +import { ApplicationInsights } from '@microsoft/applicationinsights-web'; +import { withSpan } from '@microsoft/applicationinsights-core-js'; + +const appInsights = new ApplicationInsights({ + config: { + connectionString: 'YOUR_CONNECTION_STRING' + } +}); +appInsights.loadAppInsights(); + +const tracer = appInsights.trace.getTracer('my-service'); + +// Create a span +const span = tracer.startSpan('operation'); + +// Execute code with span as active context +const result = withSpan(appInsights, span, () => { + span.setAttribute('step', 'processing'); + + // Perform work - span is active during execution + const data = processData(); + + span.setAttribute('result', 'success'); + return data; +}); + +span.end(); +``` + +### Example 2: Async Operations + +```typescript +const span = tracer.startSpan('async-operation'); + +const result = await withSpan(appInsights, span, async () => { + span.setAttribute('started', Date.now()); + + // Async work + const response = await fetch('/api/data'); + const data = await response.json(); + + span.setAttribute('completed', Date.now()); + span.setAttribute('items', data.length); + + return data; +}); + +span.end(); +console.log('Result:', result); +``` + +### Example 3: Nested Spans + +```typescript +const parentSpan = tracer.startSpan('parent-operation'); + +withSpan(core, parentSpan, () => { + parentSpan.setAttribute('level', 1); + + // Create child span + const childSpan = tracer.startSpan('child-operation'); + + withSpan(core, childSpan, () => { + childSpan.setAttribute('level', 2); + + // childSpan automatically has parentSpan as parent + console.log('Parent ID:', parentSpan.spanContext().spanId); + console.log('Child parent ID:', childSpan.spanContext().spanId); + }); + + childSpan.end(); +}); + +parentSpan.end(); +``` + +### Example 4: Passing Additional Arguments + +```typescript +function processWithContext(userId: string, action: string): string { + return `User ${userId} performed ${action}`; +} + +const span = tracer.startSpan('user-action'); + +// Pass additional arguments to callback +const result = withSpan( + core, + span, + processWithContext, + null, // thisArg + 'user123', // userId + 'checkout' // action +); + +span.end(); +console.log(result); // 'User user123 performed checkout' +``` + +### Example 5: Custom This Context + +```typescript +class UserService { + private userId: string; + + constructor(userId: string) { + this.userId = userId; + } + + process(): string { + return `Processing for user ${this.userId}`; + } +} + +const service = new UserService('user123'); +const span = tracer.startSpan('service-operation'); + +// Preserve 'this' context +const result = withSpan( + core, + span, + service.process, + service // Use service as 'this' +); + +span.end(); +console.log(result); // 'Processing for user user123' +``` + +### Example 6: Error Handling + +```typescript +const span = tracer.startSpan('risky-operation'); + +try { + withSpan(core, span, () => { + span.setAttribute('attempting', true); + + // Operation that might throw + const result = performRiskyOperation(); + + span.setStatus({ code: SpanStatusCode.OK }); + span.setAttribute('success', true); + + return result; + }); +} catch (error) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + span.setAttribute('success', false); +} finally { + span.end(); // Always end the span +} +``` + +### Example 7: Multiple Nested Operations + +```typescript +const operationSpan = tracer.startSpan('complete-checkout'); + +withSpan(core, operationSpan, async () => { + operationSpan.setAttribute('step', 'starting'); + + // Validate user + const validateSpan = tracer.startSpan('validate-user'); + await withSpan(core, validateSpan, async () => { + validateSpan.setAttribute('user.id', '12345'); + await validateUser(); + }); + validateSpan.end(); + + // Process payment + const paymentSpan = tracer.startSpan('process-payment'); + await withSpan(core, paymentSpan, async () => { + paymentSpan.setAttribute('amount', 99.99); + await processPayment(); + }); + paymentSpan.end(); + + // Send confirmation + const confirmSpan = tracer.startSpan('send-confirmation'); + await withSpan(core, confirmSpan, async () => { + confirmSpan.setAttribute('notification.type', 'email'); + await sendConfirmation(); + }); + confirmSpan.end(); + + operationSpan.setAttribute('step', 'completed'); +}); + +operationSpan.end(); +``` + +### Example 8: Background Task + +```typescript +const span = tracer.startSpan('background-task'); + +// Execute background work with trace context +withSpan(core, span, async () => { + span.setAttribute('task.type', 'data-sync'); + span.setAttribute('started', new Date().toISOString()); + + // All operations here inherit span context + await syncDataToDatabase(); + + span.setAttribute('completed', new Date().toISOString()); +}); + +span.end(); +``` + +### Example 9: Conditional Execution + +```typescript +function executeWithTracing(operation: () => void, shouldTrace: boolean) { + if (!shouldTrace) { + // Execute without tracing + return operation(); + } + + // Execute with tracing + const span = tracer.startSpan('conditional-operation'); + try { + return withSpan(core, span, operation); + } finally { + span.end(); + } +} + +// Usage +executeWithTracing(() => { + console.log('This is traced'); +}, true); + +executeWithTracing(() => { + console.log('This is not traced'); +}, false); +``` + +### Example 10: Integration with Existing Code + +```typescript +// Existing function without tracing +async function fetchUserData(userId: string) { + const response = await fetch(`/api/users/${userId}`); + return response.json(); +} + +// Add tracing without modifying the function +async function fetchUserDataWithTracing(userId: string) { + const span = tracer.startSpan('fetch-user-data'); + span.setAttribute('user.id', userId); + + try { + const result = await withSpan(core, span, () => { + return fetchUserData(userId); + }); + + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw error; + } finally { + span.end(); + } +} +``` + +## Comparison with useSpan + +`withSpan` and `useSpan` are similar but differ in how they pass parameters to the callback function. + +### withSpan + +Passes additional arguments directly to the callback: + +```typescript +withSpan(core, span, (arg1, arg2) => { + // arg1 and arg2 are your custom arguments + console.log(arg1, arg2); +}, null, 'value1', 'value2'); +``` + +### useSpan + +Passes the `ISpanScope` as the first argument: + +```typescript +useSpan(core, span, (scope, arg1, arg2) => { + // scope is the ISpanScope + // arg1 and arg2 are your custom arguments + console.log(scope, arg1, arg2); +}, null, 'value1', 'value2'); +``` + +**When to use which:** +- **`withSpan`**: When you don't need access to the span scope object +- **`useSpan`**: When you need to manually restore context or access scope properties + +See the [useSpan documentation](./useSpan.md) for more details. + +## Best Practices + +### 1. Always End Spans + +```typescript +// Good - span is ended +const span = tracer.startSpan('operation'); +try { + withSpan(core, span, () => { + // Work + }); +} finally { + span.end(); +} + +// Bad - span is never ended +const span = tracer.startSpan('operation'); +withSpan(core, span, () => { + // Work +}); // Forgot to end span! +``` + +### 2. Use with startSpan, Not startActiveSpan + +```typescript +// Good - use withSpan with startSpan +const span = tracer.startSpan('operation'); +withSpan(core, span, () => { + // Work +}); +span.end(); + +// Redundant - startActiveSpan already manages context +tracer.startActiveSpan('operation', (span) => { + // No need for withSpan here +}); +``` + +### 3. Handle Errors Appropriately + +```typescript +const span = tracer.startSpan('operation'); +try { + withSpan(core, span, () => { + // Work that might throw + throw new Error('Failed'); + }); +} catch (error) { + // Record exception + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); +} finally { + span.end(); +} +``` + +### 4. Don't Nest withSpan Unnecessarily + +```typescript +// Good - each span with its own withSpan +const span1 = tracer.startSpan('operation-1'); +withSpan(core, span1, () => { + // Work +}); +span1.end(); + +const span2 = tracer.startSpan('operation-2'); +withSpan(core, span2, () => { + // Work +}); +span2.end(); + +// Less ideal - unnecessary nesting +withSpan(core, span1, () => { + withSpan(core, span1, () => { // Redundant + // Work + }); +}); +``` + +### 5. Combine with Async/Await + +```typescript +// Good - natural async/await usage +const span = tracer.startSpan('async-op'); +try { + const result = await withSpan(core, span, async () => { + return await doAsyncWork(); + }); + span.setStatus({ code: SpanStatusCode.OK }); +} finally { + span.end(); +} +``` + +## Common Patterns + +### Pattern 1: Wrapper Functions + +```typescript +function withTracing( + operationName: string, + fn: () => T +): T { + const span = tracer.startSpan(operationName); + try { + return withSpan(core, span, fn); + } finally { + span.end(); + } +} + +// Usage +const result = withTracing('my-operation', () => { + return performWork(); +}); +``` + +### Pattern 2: Decorator Pattern + +```typescript +function traced(operationName: string) { + return function( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor + ) { + const original = descriptor.value; + + descriptor.value = function(...args: any[]) { + const span = tracer.startSpan(operationName); + try { + return withSpan(core, span, () => { + return original.apply(this, args); + }, this); + } finally { + span.end(); + } + }; + + return descriptor; + }; +} + +// Usage +class MyService { + @traced('process-data') + processData(data: any) { + // Automatically traced + return data.processed; + } +} +``` + +### Pattern 3: Context Preservation + +```typescript +function preserveContext(fn: () => T): () => T { + const span = core.getActiveSpan(); + + return () => { + if (span) { + return withSpan(core, span, fn); + } + return fn(); + }; +} + +// Usage +const contextualFn = preserveContext(() => { + console.log('Maintaining trace context'); +}); + +setTimeout(contextualFn, 1000); // Executes with preserved context +``` + +## Error Handling + +### Automatic Context Restoration + +`withSpan` guarantees context restoration even when errors occur: + +```typescript +const previousSpan = core.getActiveSpan(); +const span = tracer.startSpan('operation'); + +try { + withSpan(core, span, () => { + throw new Error('Operation failed'); + }); +} catch (error) { + // Context is restored to previousSpan + console.log(core.getActiveSpan() === previousSpan); // true +} finally { + span.end(); +} +``` + +### Error Recording + +```typescript +const span = tracer.startSpan('operation'); + +try { + withSpan(core, span, () => { + try { + performRiskyOperation(); + span.setStatus({ code: SpanStatusCode.OK }); + } catch (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw error; // Re-throw if needed + } + }); +} catch (error) { + // Handle error at outer level + console.error('Operation failed:', error); +} finally { + span.end(); +} +``` + +### Promise Rejection + +```typescript +const span = tracer.startSpan('async-operation'); + +try { + await withSpan(core, span, async () => { + const result = await fetch('/api/data'); + if (!result.ok) { + throw new Error(`HTTP ${result.status}`); + } + return result.json(); + }); + span.setStatus({ code: SpanStatusCode.OK }); +} catch (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); +} finally { + span.end(); +} +``` + +## See Also + +- [useSpan Helper](./useSpan.md) - Similar helper with scope parameter +- [Trace API Documentation](./traceApi.md) - Tracer and span management +- [OTel API Documentation](./otelApi.md) - Main OpenTelemetry API +- [Examples Guide](./examples.md) - Comprehensive usage examples +- [Main README](./README.md) - OpenTelemetry compatibility overview + +## Related Types + +- `ITraceHost` - Host interface for span management +- `IReadableSpan` - Span interface +- `ISpanScope` - Scope for restoring active span context +- `IOTelTracer` - Tracer interface for creating spans diff --git a/docs/README.md b/docs/README.md index b733ca717..cace719fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,7 @@ ES3 support has been removed from the latest version (v3.x), if required [see fo ### Docs +- [OpenTelemetry Tracing API](./OTel/README.md) - OpenTelemetry-compatible tracing API for distributed tracing - [Performance Monitoring](./PerformanceMonitoring.md) - [Dependency Listeners](./Dependency.md) - [SDK Load Failure](./SdkLoadFailure.md) diff --git a/examples/AISKU/src/utils.ts b/examples/AISKU/src/utils.ts index c47cf089d..48476e11e 100644 --- a/examples/AISKU/src/utils.ts +++ b/examples/AISKU/src/utils.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { arrForEach } from "@microsoft/applicationinsights-core-js"; -import { arrIncludes } from "@nevware21/ts-utils"; +import { arrForEach, arrIncludes } from "@nevware21/ts-utils"; export const detailsContainerId = "details-container"; export const detailsWatchList = ["baseType", "name", "time", "properties"]; diff --git a/examples/dependency/src/appinsights-init.ts b/examples/dependency/src/appinsights-init.ts index abdf3c694..0f2faf53e 100644 --- a/examples/dependency/src/appinsights-init.ts +++ b/examples/dependency/src/appinsights-init.ts @@ -3,7 +3,8 @@ import { ApplicationInsights, IConfiguration, - DependencyListenerFunction, DependencyInitializerFunction, IDependencyInitializerHandler, IDependencyListenerHandler + DependencyListenerFunction, DependencyInitializerFunction, IDependencyInitializerHandler, IDependencyListenerHandler, + OTelSpanKind } from "@microsoft/applicationinsights-web"; import { generateNewConfig } from "./utils"; @@ -103,20 +104,191 @@ export function enableAjaxPerfTrackingConfig() { return false; } -// // ****************************************************************************************************************************** -// // Snippet Initialization -// let _appInsights: any; +/** + * Example of using the new OpenTelemetry trace API + */ +export function createSpanWithTraceAPI() { + if (_appInsights) { + // Get the OpenTelemetry trace API + const trace = _appInsights.trace; + + // Get a tracer instance + const tracer = trace.getTracer("example-service", "1.0.0"); + + // Create a span using the OpenTelemetry API + const span = tracer.startSpan("api-request", { + kind: OTelSpanKind.SERVER, + attributes: { + "http.method": "POST", + "http.url": "/api/users", + "service.name": "user-service" + } + }); -// export function initApplicationInsights() { - -// if (!_appInsights) { -// _appInsights = (window as any).appInsights; -// return _appInsights; -// } + if (!span) { + console.warn("Failed to create span in createSpanWithTraceAPI"); + return null; + } + + // Set additional attributes + span.setAttribute("user.id", "12345"); + span.setAttributes({ + "request.size": 1024, + "response.status_code": 200 + }); -// return _appInsights; -// } + // Simulate some async work + setTimeout(() => { + if (span) { + // Update span name if needed + span.updateName("api-request-completed"); + + // End the span - this will automatically create telemetry + span.end(); + } + }, 800); -// // *********************************************************************************************************************************** + return span; + } + return null; +} + +/** + * Example of using multiple tracers for different components + */ +export function createMultipleTracers() { + if (_appInsights) { + const trace = _appInsights.trace; + + // Get different tracers for different services + const userServiceTracer = trace.getTracer("user-service", "1.2.3"); + const paymentServiceTracer = trace.getTracer("payment-service", "2.1.0"); + + // Create spans from different tracers + const userSpan = userServiceTracer.startSpan("validate-user", { + attributes: { "component": "authentication" } + }); + + const paymentSpan = paymentServiceTracer.startSpan("process-payment", { + attributes: { "component": "billing" } + }); + + if (!userSpan || !paymentSpan) { + console.warn("Failed to create one or more spans in createMultipleTracers"); + return null; + } + + // End spans after some work + setTimeout(() => { + if (userSpan) { + userSpan.end(); + } + if (paymentSpan) { + paymentSpan.end(); + } + }, 500); + + return { userSpan, paymentSpan }; + } + return null; +} + +/** + * Example of using the new startSpan API for distributed tracing + */ +export function createExampleSpan() { + if (_appInsights) { + // Create a span for a user operation + const span = _appInsights.core.startSpan("user-checkout", { + kind: OTelSpanKind.SERVER, + attributes: { + "user.action": "checkout", + "cart.items": 3, + "component": "shopping-cart" + } + }); + + // Check if span is available before using it + if (!span) { + console.warn("Span creation failed - tracing may not be enabled"); + return null; + } + + // Set additional attributes + span.setAttribute("order.total", 99.99); + span.setAttributes({ + "payment.method": "credit_card", + "shipping.method": "standard" + }); + + // Simulate some async work + setTimeout(() => { + if (span) { + // Update span name if needed + span.updateName("user-checkout-completed"); + + // End the span + span.end(); + } + }, 1000); + + return span; + } + return null; +} + +/** + * Example of creating child spans for nested operations + */ +export function createChildSpanExample() { + if (_appInsights) { + // Create parent span + const parentSpan = _appInsights.core.startSpan("process-order", { + kind: OTelSpanKind.INTERNAL, + attributes: { + "order.id": "12345" + } + }); + + // Create child span for payment processing + const paymentSpan = _appInsights.core.startSpan("process-payment", { + kind: OTelSpanKind.CLIENT, + attributes: { + "payment.processor": "stripe", + "amount": 99.99 + } + }); + + // Check if spans are available before using them + if (!parentSpan || !paymentSpan) { + console.warn("Span creation failed - tracing may not be enabled"); + // Clean up any successfully created spans + if (parentSpan) { + parentSpan.end(); + } + if (paymentSpan) { + paymentSpan.end(); + } + return null; + } + + // Simulate payment processing + setTimeout(() => { + if (paymentSpan) { + paymentSpan.setAttribute("payment.status", "success"); + paymentSpan.end(); + } + + // End parent span + if (parentSpan) { + parentSpan.setAttribute("order.status", "completed"); + parentSpan.end(); + } + }, 500); + + return { parentSpan, paymentSpan }; + } + return null; +} diff --git a/examples/dependency/src/startSpan-example.ts b/examples/dependency/src/startSpan-example.ts new file mode 100644 index 000000000..b97013ab8 --- /dev/null +++ b/examples/dependency/src/startSpan-example.ts @@ -0,0 +1,377 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IReadableSpan } from "@microsoft/applicationinsights-core-js"; +import { + ApplicationInsights, + OTelSpanKind +} from "@microsoft/applicationinsights-web"; + +/** + * Example demonstrating the simplified OpenTelemetry-like span interface + * which focuses on ApplicationInsights telemetry needs. + * + * Key features of the simplified interface: + * - setAttribute/setAttributes for additional properties + * - updateName for changing span names + * - end() to complete spans + * - isRecording() to check if span is active + * - spanContext() for getting trace/span IDs + * + * Note: This implementation does NOT include: + * - addEvent() - events are not part of this simplified interface + * - setStatus() - status tracking is not part of this simplified interface + * + * Attributes set on spans are sent as additional properties with ApplicationInsights telemetry. + */ +export class StartSpanExample { + private _appInsights: ApplicationInsights; + + constructor(appInsights: ApplicationInsights) { + this._appInsights = appInsights; + } + + /** + * Basic span creation example showing simplified interface + */ + public basicSpanExample(): IReadableSpan | null { + if (!this._appInsights) { + return null; + } + + // Create a root span with attributes + const span = this._appInsights.appInsights.core.startSpan("user-action", { + kind: OTelSpanKind.SERVER, + attributes: { + "user.id": "user123", + "action.type": "button_click", + "component": "ui" + } + }); + + // Check if span is available before using it + if (!span) { + console.warn("Span creation failed - tracing may not be enabled"); + return null; + } + + // Set additional individual attributes + span.setAttribute("session.id", "session456"); + span.setAttribute("page.url", window.location.href); + + // Set multiple attributes at once + span.setAttributes({ + "browser.name": navigator.userAgent, + "screen.resolution": `${screen.width}x${screen.height}`, + "timestamp": Date.now() + }); + + // Update the span name if needed + span.updateName("user-button-click"); + + // Check if span is still recording + console.log("Span is recording:", span.isRecording()); + + // Get span context for trace/span IDs + const spanContext = span.spanContext(); + console.log("Trace ID:", spanContext.traceId); + console.log("Span ID:", spanContext.spanId); + + // End the span after some work + setTimeout(() => { + if (span) { + span.end(); + console.log("Span ended. Still recording:", span.isRecording()); + } + }, 1000); + + return span; + } + + /** + * Example showing child span creation for nested operations + */ + public nestedSpanExample(): { parent: IReadableSpan, child: IReadableSpan } | null { + if (!this._appInsights) { + return null; + } + + // Create parent span for overall operation + const parentSpan = this._appInsights.appInsights.core.startSpan("data-processing", { + kind: OTelSpanKind.INTERNAL, + attributes: { + "operation.type": "batch_process", + "data.source": "user_upload" + } + }); + + // Create child span for specific sub-operation + // The child will automatically inherit the parent's trace context + const childSpan = this._appInsights.appInsights.core.startSpan("validate-data", { + kind: OTelSpanKind.INTERNAL, + attributes: { + "validation.rules": "strict", + "data.format": "csv" + } + }); + + // Check if spans are available before using them + if (!parentSpan || !childSpan) { + console.warn("Span creation failed - tracing may not be enabled"); + // Clean up any successfully created spans + if (parentSpan) { + parentSpan.end(); + } + if (childSpan) { + childSpan.end(); + } + return null; + } + + // Both spans share the same trace ID but have different span IDs + console.log("Parent Trace ID:", parentSpan.spanContext().traceId); + console.log("Child Trace ID:", childSpan.spanContext().traceId); + console.log("Parent Span ID:", parentSpan.spanContext().spanId); + console.log("Child Span ID:", childSpan.spanContext().spanId); + + // Simulate nested operations + setTimeout(() => { + // Child operation completes first + if (childSpan) { + childSpan.setAttribute("validation.result", "passed"); + childSpan.setAttribute("records.validated", 1500); + childSpan.end(); + } + + // Parent operation completes after child + setTimeout(() => { + if (parentSpan) { + parentSpan.setAttribute("processing.result", "success"); + parentSpan.setAttribute("total.records", 1500); + parentSpan.end(); + } + }, 200); + }, 800); + + return { parent: parentSpan, child: childSpan }; + } + + /** + * Example showing HTTP request tracking with spans + */ + public httpRequestSpanExample(): IReadableSpan | null { + if (!this._appInsights) { + return null; + } + + // Create span for HTTP request + const httpSpan = this._appInsights.appInsights.core.startSpan("api-call", { + kind: OTelSpanKind.CLIENT, + attributes: { + "http.method": "POST", + "http.url": "https://api.example.com/data", + "http.user_agent": navigator.userAgent + } + }); + + // Check if span is available before using it + if (!httpSpan) { + console.warn("HTTP span creation failed - tracing may not be enabled"); + return null; + } + + // Simulate making an HTTP request + fetch("https://api.example.com/data", { + method: "POST", + body: JSON.stringify({ test: "data" }), + headers: { + "Content-Type": "application/json" + } + }).then(response => { + // Set response attributes + if (httpSpan) { + httpSpan.setAttribute("http.status_code", response.status); + httpSpan.setAttribute("http.response.size", response.headers.get("content-length") || 0); + + if (response.ok) { + httpSpan.setAttribute("http.result", "success"); + } else { + httpSpan.setAttribute("http.result", "error"); + httpSpan.setAttribute("error.message", `HTTP ${response.status}`); + } + } + + return response.json(); + }).then(data => { + if (httpSpan) { + httpSpan.setAttribute("response.records", data?.length || 0); + httpSpan.end(); + } + }).catch(error => { + if (httpSpan) { + httpSpan.setAttribute("http.result", "error"); + httpSpan.setAttribute("error.message", error.message); + httpSpan.setAttribute("error.type", error.name); + httpSpan.end(); + } + }); + + return httpSpan; + } + + /** + * Example showing manual trace context management + */ + public manualTraceContextExample(): void { + if (!this._appInsights) { + return; + } + + // Get current trace context + const currentContext = this._appInsights.appInsights.core.getTraceCtx(); + console.log("Current trace context:", currentContext); + + // Create a span - this automatically becomes the active trace context + const span = this._appInsights.appInsights.core.startSpan("background-task", { + kind: OTelSpanKind.INTERNAL, + attributes: { + "task.priority": "low" + } + }); + + // Check if span is available before using it + if (!span) { + console.warn("Background task span creation failed - tracing may not be enabled"); + return; + } + + // The span's context is now active for distributed tracing + const newContext = this._appInsights.appInsights.core.getTraceCtx(); + console.log("New trace context after span creation:", newContext); + + // Any HTTP requests or other telemetry will now include this trace context + setTimeout(() => { + if (span) { + span.setAttribute("task.result", "completed"); + span.end(); + } + + // Context remains until explicitly changed or another span is created + console.log("Trace context after span end:", this._appInsights?.appInsights.core.getTraceCtx()); + }, 500); + } + + /** + * Example showing span attribute best practices for ApplicationInsights + */ + public attributeBestPracticesExample(): IReadableSpan | null { + if (!this._appInsights) { + return null; + } + + const span = this._appInsights.appInsights.core.startSpan("business-operation", { + kind: OTelSpanKind.INTERNAL, + attributes: { + // Use semantic naming conventions + "operation.name": "calculate_price", + "operation.version": "1.2.0", + + // Include business context + "customer.tier": "premium", + "product.category": "electronics", + + // Include technical context + "service.name": "pricing-service", + "service.version": "2.1.3" + } + }); + + // Check if span is available before using it + if (!span) { + console.warn("Span creation failed - tracing may not be enabled"); + return null; + } + + // Add dynamic attributes during operation + span.setAttributes({ + "calculation.start_time": Date.now(), + "input.items_count": 5, + "pricing.rules_applied": "discount,tax,shipping" + }); + + // Simulate business logic + setTimeout(() => { + // Ensure span is still available in callback + if (span) { + // Add result attributes + span.setAttribute("calculation.duration_ms", 150); + span.setAttribute("output.base_price", 299.99); + span.setAttribute("output.final_price", 254.99); + span.setAttribute("discount.applied", 45.00); + + // These attributes will be sent as additional properties + // with ApplicationInsights telemetry for correlation and analysis + span.end(); + } + }, 150); + + return span; + } +} + +/** + * Helper function to initialize ApplicationInsights with startSpan support + */ +export function createApplicationInsightsWithSpanSupport(connectionString: string): ApplicationInsights { + const appInsights = new ApplicationInsights({ + config: { + connectionString: connectionString, + // Enable distributed tracing for span context propagation + disableAjaxTracking: false, + disableFetchTracking: false, + enableCorsCorrelation: true, + enableRequestHeaderTracking: true, + enableResponseHeaderTracking: true + } + }); + + appInsights.loadAppInsights(); + return appInsights; +} + +/** + * Usage example + */ +export function runStartSpanExamples() { + // Initialize ApplicationInsights + const appInsights = createApplicationInsightsWithSpanSupport( + "InstrumentationKey=YOUR_INSTRUMENTATION_KEY_GOES_HERE" + ); + + // Create example instance + const examples = new StartSpanExample(appInsights); + + // Run examples + console.log("Running basic span example..."); + examples.basicSpanExample(); + + setTimeout(() => { + console.log("Running nested span example..."); + examples.nestedSpanExample(); + }, 1500); + + setTimeout(() => { + console.log("Running HTTP request span example..."); + examples.httpRequestSpanExample(); + }, 3000); + + setTimeout(() => { + console.log("Running manual trace context example..."); + examples.manualTraceContextExample(); + }, 4500); + + setTimeout(() => { + console.log("Running attribute best practices example..."); + examples.attributeBestPracticesExample(); + }, 6000); +} diff --git a/examples/startSpan/package.json b/examples/startSpan/package.json new file mode 100644 index 000000000..43fa43c47 --- /dev/null +++ b/examples/startSpan/package.json @@ -0,0 +1,62 @@ +{ + "name": "@microsoft/applicationinsights-example-startspan", + "author": "Microsoft Application Insights Team", + "version": "3.3.10", + "description": "Microsoft Application Insights startSpan Example", + "homepage": "https://github.com/microsoft/ApplicationInsights-JS#readme", + "keywords": [ + "azure", + "cloud", + "script errors", + "microsoft", + "application insights", + "browser performance monitoring", + "web analytics", + "opentelemetry", + "tracing", + "spans", + "example" + ], + "scripts": { + "clean": "git clean -xdf", + "build": "npm run build:esm && npm run build:browser", + "build:esm": "grunt example-startspan", + "build:browser": "rollup -c rollup.config.js --bundleConfigAsCjs", + "rebuild": "npm run build", + "test": "", + "mintest": "", + "perftest": "", + "lint": "tslint -p tsconfig.json", + "ai-min": "", + "ai-restore": "" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/ApplicationInsights-JS/tree/main/examples/startSpan" + }, + "license": "MIT", + "sideEffects": false, + "devDependencies": { + "@microsoft/applicationinsights-rollup-plugin-uglify3-js": "1.0.0", + "@microsoft/applicationinsights-rollup-es5": "1.0.2", + "grunt": "^1.5.3", + "grunt-cli": "^1.4.3", + "@rollup/plugin-commonjs": "^24.0.0", + "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-replace": "^5.0.2", + "rollup": "^3.20.0", + "rollup-plugin-cleanup": "^3.2.1", + "rollup-plugin-sourcemaps": "^0.6.3", + "typescript": "^4.9.3" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + }, + "dependencies": { + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/dynamicproto-js": "^2.0.3", + "@microsoft/applicationinsights-web": "3.3.10", + "@microsoft/applicationinsights-core-js": "3.3.10", + "@nevware21/ts-utils": ">= 0.11.8 < 2.x" + } +} \ No newline at end of file diff --git a/examples/startSpan/rollup.config.js b/examples/startSpan/rollup.config.js new file mode 100644 index 000000000..24fe1d3f9 --- /dev/null +++ b/examples/startSpan/rollup.config.js @@ -0,0 +1,100 @@ +import nodeResolve from "@rollup/plugin-node-resolve"; +import { uglify } from "@microsoft/applicationinsights-rollup-plugin-uglify3-js"; +import replace from "@rollup/plugin-replace"; +import cleanup from "rollup-plugin-cleanup"; +import dynamicRemove from "@microsoft/dynamicproto-js/tools/rollup/dist/node/removedynamic"; +import { es5Poly, es5Check, importCheck } from "@microsoft/applicationinsights-rollup-es5"; +import { updateDistEsmFiles } from "../../tools/updateDistEsm/updateDistEsm"; + +const version = require("./package.json").version; + +const workerName = "startspan-example-index"; + +const banner = [ + "/*!", + ` * Application Insights JavaScript SDK Example - startSpan, ${version}`, + " * Copyright (c) Microsoft and contributors. All rights reserved.", + " */" +].join("\n"); + +const replaceValues = { + "// Copyright (c) Microsoft Corporation. All rights reserved.": "", + "// Licensed under the MIT License.": "" +}; + +function doCleanup() { + return cleanup({ + comments: [ + 'some', + /^.\s*@DynamicProtoStub/i, + /^\*\*\s*@class\s*$/ + ] + }) +} + +const browserRollupConfigFactory = (name, isProduction, format = "umd", extension = "") => { + const browserRollupConfig = { + input: `dist-es5/${name}.js`, + output: { + file: `browser/${name}${extension ? "." +extension : ""}.js`, + banner: banner, + format: format, + name: "Microsoft.ApplicationInsights.Example", + extend: true, + freeze: false, + sourcemap: true + }, + treeshake: { + propertyReadSideEffects: false, + moduleSideEffects: false, + tryCatchDeoptimization: false, + correctVarValueBeforeDeclaration: false + }, + plugins: [ + dynamicRemove(), + replace({ + preventAssignment: true, + delimiters: ["", ""], + values: replaceValues + }), + // This just makes sure we are importing the example dependencies correctly + importCheck({ exclude: [ "startspan-example-index" ] }), + nodeResolve({ + module: true, + browser: true, + preferBuiltins: false + }), + doCleanup(), + es5Poly(), + es5Check() + ] + }; + + if (isProduction) { + browserRollupConfig.output.file = `browser/${name}${extension ? "." +extension : ""}.min.js`; + browserRollupConfig.plugins.push( + uglify({ + ie8: false, + ie: true, + compress: { + ie: true, + passes:3, + unsafe: true, + }, + output: { + ie: true, + preamble: banner, + webkit:true + } + }) + ); + } + + return browserRollupConfig; +}; + +updateDistEsmFiles(replaceValues, banner); + +export default [ + browserRollupConfigFactory(workerName, false, "iife", "gbl") +]; \ No newline at end of file diff --git a/examples/startSpan/src/startSpanExample.ts b/examples/startSpan/src/startSpanExample.ts new file mode 100644 index 000000000..55c65e810 --- /dev/null +++ b/examples/startSpan/src/startSpanExample.ts @@ -0,0 +1,196 @@ +// Example usage of the new startSpan implementation with withSpan helper +// This shows how to use the startSpan API to create spans and set distributed trace context +// +// The withSpan helper provides several benefits: +// 1. Automatic span context management - sets span as active during execution +// 2. Context restoration - restores previous active span after execution +// 3. Exception safety - ensures context is restored even if function throws +// 4. Cleaner code - eliminates manual span context management +// 5. Nested span support - handles complex span hierarchies automatically + +import { AppInsightsCore, OTelSpanKind, eOTelSpanStatusCode, withSpan } from "@microsoft/applicationinsights-core-js"; + +// Example 1: Basic span creation with withSpan helper +function exampleBasicSpanUsage() { + const core = new AppInsightsCore(); + + // Initialize core (simplified) + core.initialize({ + instrumentationKey: "your-ikey-here" + }, []); + + // Create a span - this will automatically set it as the active trace context + const span = core.startSpan("my-operation", { + kind: OTelSpanKind.INTERNAL, + attributes: { + "operation.type": "example", + "user.id": "123" + } + }); + + if (span) { + // Use withSpan to execute work within the span's context + const result = withSpan(core, span, () => { + // Do some work within the span context... + span.setAttribute("result", "success"); + // span.addEvent("Processing started"); // Not implemented yet + + // Create a child span that will automatically inherit the current active context + const childSpan = core.startSpan("child-operation", { + kind: OTelSpanKind.CLIENT, + attributes: { + "http.method": "GET", + "http.url": "https://api.example.com/data" + } + }); + + if (childSpan) { + // Execute child work within its own span context + withSpan(core, childSpan, () => { + // childSpan.addEvent("API call completed"); // Not implemented yet + childSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + }); + childSpan.end(); + } + + return "operation completed"; + }); + + // Complete the parent span + // span.addEvent("Processing completed"); // Not implemented yet + span.setStatus({ code: eOTelSpanStatusCode.OK }); + span.end(); + + console.log("Operation result:", result); + } +} + +// Example 2: Manual parent context management with withSpan +function exampleManualParentContext() { + const core = new AppInsightsCore(); + + // Get the current active trace context + const currentTraceCtx = core.getTraceCtx(); + + if (currentTraceCtx) { + // Create a new span with explicit parent + const span = core.startSpan("background-task", { + kind: OTelSpanKind.INTERNAL, + attributes: { + "task.type": "background" + } + }, currentTraceCtx); + + if (span) { + // Use withSpan to execute work within the span context + // This ensures the span is properly active during execution + withSpan(core, span, () => { + // span.addEvent("Task started"); // Not implemented yet + + // Simulate some background work + // In real scenarios, this could make HTTP requests, database calls, etc. + // All will inherit this span's context automatically + console.log("Executing background task with trace context:", span.spanContext().traceId); + + // Create a child operation within this context + const childSpan = core.startSpan("background-subtask", { + kind: OTelSpanKind.INTERNAL, + attributes: { + "subtask.type": "data-processing" + } + }); + + if (childSpan) { + withSpan(core, childSpan, () => { + // This subtask automatically inherits the parent context + childSpan.setAttribute("processed.items", 42); + }); + childSpan.end(); + } + + return "background work completed"; + }); + + // span.addEvent("Task completed"); // Not implemented yet + span.end(); + } + } +} + +// Example 3: Integration with existing telemetry using withSpan +function exampleWithTelemetry() { + const core = new AppInsightsCore(); + + // Start a span for a user action + const userActionSpan = core.startSpan("user-checkout", { + kind: OTelSpanKind.SERVER, + attributes: { + "user.id": "user123", + "action": "checkout", + "cart.items": 5 + } + }); + + if (userActionSpan) { + // Use withSpan to execute the entire checkout process within the span context + const result = withSpan(core, userActionSpan, () => { + // Now all subsequent telemetry will inherit this trace context + // including dependency calls, custom events, etc. + + // Simulate processing steps + // userActionSpan.addEvent("Validating cart"); // Not implemented yet + console.log("Processing checkout for user in trace context:", userActionSpan.spanContext().traceId); + + // Create child span for payment processing + const paymentSpan = core.startSpan("process-payment", { + kind: OTelSpanKind.CLIENT, + attributes: { + "payment.method": "credit_card", + "payment.amount": 99.99 + } + }); + + if (paymentSpan) { + // Execute payment processing within its own span context + const paymentResult = withSpan(core, paymentSpan, () => { + // paymentSpan.addEvent("Payment authorized"); // Not implemented yet + paymentSpan.setAttribute("payment.processor", "stripe"); + paymentSpan.setAttribute("payment.status", "authorized"); + paymentSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + + return { success: true, transactionId: "txn_12345" }; + }); + + paymentSpan.end(); + + // Update parent span with payment result + userActionSpan.setAttribute("payment.success", paymentResult.success); + userActionSpan.setAttribute("transaction.id", paymentResult.transactionId); + + return { + checkoutSuccess: true, + payment: paymentResult, + completedAt: new Date().toISOString() + }; + } + + return { checkoutSuccess: false, error: "Payment processing failed" }; + }); + + // Complete the user action span + // userActionSpan.addEvent("Checkout completed"); // Not implemented yet + userActionSpan.setStatus({ code: eOTelSpanStatusCode.OK }); + userActionSpan.end(); + + console.log("Checkout completed:", result); + return result; + } + + return null; +} + +export { + exampleBasicSpanUsage, + exampleManualParentContext, + exampleWithTelemetry +}; diff --git a/examples/startSpan/src/startspan-example-index.ts b/examples/startSpan/src/startspan-example-index.ts new file mode 100644 index 000000000..b4fbdfb07 --- /dev/null +++ b/examples/startSpan/src/startspan-example-index.ts @@ -0,0 +1,8 @@ +// startSpan Example Index +// This file exports the startSpan example functions for use in browser builds + +export { + exampleBasicSpanUsage, + exampleManualParentContext, + exampleWithTelemetry +} from "./startSpanExample"; \ No newline at end of file diff --git a/examples/startSpan/tsconfig.json b/examples/startSpan/tsconfig.json new file mode 100644 index 000000000..cc5fe185f --- /dev/null +++ b/examples/startSpan/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "es6", + "sourceMap": true, + "inlineSources": true, + "moduleResolution": "node", + "strict": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "importHelpers": true, + "allowJs": true, + "resolveJsonModule": true, + "declaration": true, + "declarationDir": "build/types", + "outDir": "dist-es5", + "rootDir": "./src", + "removeComments": false + }, + "include": [ + "./src" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsExtensionSize.tests.ts b/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsExtensionSize.tests.ts index f61505caf..492192483 100644 --- a/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsExtensionSize.tests.ts +++ b/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsExtensionSize.tests.ts @@ -51,7 +51,7 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AnalyticsExtensionSizeCheck extends AITestClass { - private readonly MAX_DEFLATE_SIZE = 25; + private readonly MAX_DEFLATE_SIZE = 27; private readonly rawFilePath = "../dist/es5/applicationinsights-analytics-js.min.js"; private readonly prodFilePaath = "../browser/es5/applicationinsights-analytics-js.min.js" diff --git a/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsPlugin.tests.ts b/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsPlugin.tests.ts index f444f20c1..fb399e909 100644 --- a/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsPlugin.tests.ts +++ b/extensions/applicationinsights-analytics-js/Tests/Unit/src/AnalyticsPlugin.tests.ts @@ -5,7 +5,17 @@ import { import { SinonStub, SinonSpy } from 'sinon'; import { Exception, SeverityLevel, Event, Trace, PageViewPerformance, IConfig, IExceptionInternal, - AnalyticsPluginIdentifier, IAppInsights, Metric, PageView, RemoteDependencyData, utlCanUseLocalStorage, createDomEvent + AnalyticsPluginIdentifier, IAppInsights, Metric, PageView, RemoteDependencyData, utlCanUseLocalStorage, createDomEvent, + ExceptionDataType, + TraceDataType, + PageViewPerformanceDataType, + RemoteDependencyDataType, + PageViewDataType, + MetricDataType, + EventDataType, + ExceptionEnvelopeType, + TraceEnvelopeType, + PageViewPerformanceEnvelopeType } from "@microsoft/applicationinsights-common"; import { ITelemetryItem, AppInsightsCore, IPlugin, IConfiguration, IAppInsightsCore, setEnableEnvMocks, getLocation, dumpObj, __getRegisteredEvents, createCookieMgr, findAllScripts } from "@microsoft/applicationinsights-core-js"; import { Sender } from "@microsoft/applicationinsights-channel-js" @@ -711,10 +721,10 @@ export class AnalyticsPluginTests extends AITestClass { }; // Test - test(() => appInsights.trackException({exception: new Error(), severityLevel: SeverityLevel.Critical}), Exception.envelopeType, Exception.dataType) - test(() => appInsights.trackException({error: new Error(), severityLevel: SeverityLevel.Critical}), Exception.envelopeType, Exception.dataType) - test(() => appInsights.trackTrace({message: "some string"}), Trace.envelopeType, Trace.dataType); - test(() => appInsights.trackPageViewPerformance({name: undefined, uri: undefined, measurements: {somefield: 123}}, {vpHeight: 123}), PageViewPerformance.envelopeType, PageViewPerformance.dataType, () => { + test(() => appInsights.trackException({exception: new Error(), severityLevel: SeverityLevel.Critical}), ExceptionEnvelopeType, ExceptionDataType) + test(() => appInsights.trackException({error: new Error(), severityLevel: SeverityLevel.Critical}), ExceptionEnvelopeType, ExceptionDataType) + test(() => appInsights.trackTrace({message: "some string"}), TraceEnvelopeType, TraceDataType); + test(() => appInsights.trackPageViewPerformance({name: undefined, uri: undefined, measurements: {somefield: 123}}, {vpHeight: 123}), PageViewPerformanceEnvelopeType, PageViewPerformanceDataType, () => { Assert.deepEqual(undefined, envelope.baseData.properties, 'Properties does not exist in Part B'); }); } @@ -1812,7 +1822,7 @@ export class AnalyticsPluginTests extends AITestClass { // to access/ modify the contents of an envelope. initializer: (envelope) => { if (envelope.baseType === - Trace.dataType) { + TraceDataType) { const telemetryItem = envelope.baseData; telemetryItem.message = messageOverride; telemetryItem.properties = telemetryItem.properties || {}; @@ -2201,19 +2211,19 @@ export class AnalyticsPluginTests extends AITestClass { const baseType = payload.data.baseType; // call the appropriate Validate depending on the baseType switch (baseType) { - case Event.dataType: + case EventDataType: return EventValidator.EventValidator.Validate(payload, baseType); - case Trace.dataType: + case TraceDataType: return TraceValidator.TraceValidator.Validate(payload, baseType); - case Exception.dataType: + case ExceptionDataType: return ExceptionValidator.ExceptionValidator.Validate(payload, baseType); - case Metric.dataType: + case MetricDataType: return MetricValidator.MetricValidator.Validate(payload, baseType); - case PageView.dataType: + case PageViewDataType: return PageViewValidator.PageViewValidator.Validate(payload, baseType); - case PageViewPerformance.dataType: + case PageViewPerformanceDataType: return PageViewPerformanceValidator.PageViewPerformanceValidator.Validate(payload, baseType); - case RemoteDependencyData.dataType: + case RemoteDependencyDataType: return RemoteDepdencyValidator.RemoteDepdencyValidator.Validate(payload, baseType); default: diff --git a/extensions/applicationinsights-analytics-js/Tests/Unit/src/TelemetryItemCreator.tests.ts b/extensions/applicationinsights-analytics-js/Tests/Unit/src/TelemetryItemCreator.tests.ts index 637ace410..ca9da19cb 100644 --- a/extensions/applicationinsights-analytics-js/Tests/Unit/src/TelemetryItemCreator.tests.ts +++ b/extensions/applicationinsights-analytics-js/Tests/Unit/src/TelemetryItemCreator.tests.ts @@ -12,6 +12,18 @@ import { IMetricTelemetry, RemoteDependencyData, IDependencyTelemetry, + PageViewPerformanceDataType, + PageViewDataType, + EventDataType, + TraceDataType, + MetricDataType, + RemoteDependencyDataType, + PageViewPerformanceEnvelopeType, + PageViewEnvelopeType, + EventEnvelopeType, + TraceEnvelopeType, + MetricEnvelopeType, + RemoteDependencyEnvelopeType, } from '@microsoft/applicationinsights-common'; import { AnalyticsPlugin } from '../../../src/JavaScriptSDK/AnalyticsPlugin' import { @@ -65,8 +77,8 @@ export class TelemetryItemCreatorTests extends AITestClass { // act const telemetryItem = TelemetryItemCreator.create( pageViewPerformance, - PageViewPerformance.dataType, - PageViewPerformance.envelopeType, + PageViewPerformanceDataType, + PageViewPerformanceEnvelopeType, this._core.logger, properties); @@ -97,8 +109,8 @@ export class TelemetryItemCreatorTests extends AITestClass { // act const telemetryItem = TelemetryItemCreator.create( pageView, - PageView.dataType, - PageView.envelopeType, + PageViewDataType, + PageViewEnvelopeType, this._core.logger, properties); @@ -127,8 +139,8 @@ export class TelemetryItemCreatorTests extends AITestClass { // act const telemetryItem = TelemetryItemCreator.create( event, - EventTelemetry.dataType, - EventTelemetry.envelopeType, + EventDataType, + EventEnvelopeType, this._appInsights.diagLog(), customProperties); @@ -161,8 +173,8 @@ export class TelemetryItemCreatorTests extends AITestClass { // act const telemetryItem = TelemetryItemCreator.create( pageView, - PageView.dataType, - PageView.envelopeType, + PageViewDataType, + PageViewEnvelopeType, this._core.logger, properties); @@ -173,7 +185,7 @@ export class TelemetryItemCreatorTests extends AITestClass { Assert.equal("PageviewData", telemetryItem.baseType, "telemetryItem.baseType"); Assert.equal("testUri", telemetryItem.baseData.uri, "telemetryItem.baseData.uri"); Assert.equal("testName", telemetryItem.baseData.name, "telemetryItem.baseData.name"); - Assert.deepEqual({"propKey1":"PropVal1"},telemetryItem.data, "telemetryItem.data"); + Assert.deepEqual({ "propKey1": "PropVal1" }, telemetryItem.data, "telemetryItem.data"); } }); @@ -192,8 +204,8 @@ export class TelemetryItemCreatorTests extends AITestClass { // act const telemetryItem = TelemetryItemCreator.create( trace, - Trace.dataType, - Trace.envelopeType, + TraceDataType, + TraceEnvelopeType, this._core.logger, customProperties); @@ -220,10 +232,10 @@ export class TelemetryItemCreatorTests extends AITestClass { // act const telemetryItem = TelemetryItemCreator.create( metric, - Metric.dataType, - Metric.envelopeType, - this._core.logger, - ); + MetricDataType, + MetricEnvelopeType, + this._core.logger + ); // assert Assert.ok(telemetryItem); @@ -249,10 +261,10 @@ export class TelemetryItemCreatorTests extends AITestClass { // act const telemetryItem = TelemetryItemCreator.create( dependency, - RemoteDependencyData.dataType, - RemoteDependencyData.envelopeType, + RemoteDependencyDataType, + RemoteDependencyEnvelopeType, this._core.logger, - ); + ); // assert Assert.ok(telemetryItem); diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts index 61034fbba..784ccd284 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts @@ -5,24 +5,25 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { - AnalyticsPluginIdentifier, Event as EventTelemetry, Exception, IAppInsights, IAutoExceptionTelemetry, IConfig, IDependencyTelemetry, - IEventTelemetry, IExceptionInternal, IExceptionTelemetry, IMetricTelemetry, IPageViewPerformanceTelemetry, - IPageViewPerformanceTelemetryInternal, IPageViewTelemetry, IPageViewTelemetryInternal, ITraceTelemetry, Metric, PageView, - PageViewPerformance, RemoteDependencyData, Trace, createDomEvent, createTelemetryItem, dataSanitizeString, eSeverityLevel, + AnalyticsPluginIdentifier, EventDataType, EventEnvelopeType, Exception, ExceptionDataType, ExceptionEnvelopeType, IAppInsights, + IAutoExceptionTelemetry, IConfig, IDependencyTelemetry, IEventTelemetry, IExceptionInternal, IExceptionTelemetry, IMetricTelemetry, + IPageViewPerformanceTelemetry, IPageViewPerformanceTelemetryInternal, IPageViewTelemetry, IPageViewTelemetryInternal, ITraceTelemetry, + MetricDataType, MetricEnvelopeType, PageViewDataType, PageViewEnvelopeType, PageViewPerformanceDataType, PageViewPerformanceEnvelopeType, + RemoteDependencyDataType, TraceDataType, TraceEnvelopeType, createDomEvent, createTelemetryItem, dataSanitizeString, eSeverityLevel, isCrossOriginError, strNotSpecified, utlDisableStorage, utlEnableStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-common"; import { - BaseTelemetryPlugin, IAppInsightsCore, IConfigDefaults, IConfiguration, ICookieMgr, ICustomProperties, IExceptionConfig, - IInstrumentCallDetails, IPlugin, IProcessTelemetryContext, IProcessTelemetryUnloadContext, ITelemetryInitializerHandler, ITelemetryItem, - ITelemetryPluginChain, ITelemetryUnloadState, InstrumentEvent, TelemetryInitializerFunction, _eInternalMessageId, arrForEach, - cfgDfBoolean, cfgDfMerge, cfgDfSet, cfgDfString, cfgDfValidate, createProcessTelemetryContext, createUniqueNamespace, dumpObj, - eLoggingSeverity, eventOff, eventOn, fieldRedaction, findAllScripts, generateW3CId, getDocument, getExceptionName, getHistory, - getLocation, getWindow, hasHistory, hasWindow, isFunction, isNullOrUndefined, isString, isUndefined, mergeEvtNamespace, onConfigChange, - safeGetCookieMgr, strUndefined, throwError + BaseTelemetryPlugin, IAppInsightsCore, IConfigDefaults, IConfiguration, ICookieMgr, ICustomProperties, IDistributedTraceContext, + IExceptionConfig, IInstrumentCallDetails, IPlugin, IProcessTelemetryContext, IProcessTelemetryUnloadContext, + ITelemetryInitializerHandler, ITelemetryItem, ITelemetryPluginChain, ITelemetryUnloadState, InstrumentEvent, + TelemetryInitializerFunction, _eInternalMessageId, arrForEach, cfgDfBoolean, cfgDfMerge, cfgDfSet, cfgDfString, cfgDfValidate, + createDistributedTraceContext, createProcessTelemetryContext, createUniqueNamespace, dumpObj, eLoggingSeverity, eventOff, eventOn, + fieldRedaction, findAllScripts, generateW3CId, getDocument, getExceptionName, getHistory, getLocation, getWindow, hasHistory, hasWindow, + isFunction, isNullOrUndefined, isString, isUndefined, mergeEvtNamespace, onConfigChange, safeGetCookieMgr, strUndefined, throwError } from "@microsoft/applicationinsights-core-js"; import { IAjaxMonitorPlugin } from "@microsoft/applicationinsights-dependencies-js"; import { isArray, isError, objDeepFreeze, objDefine, scheduleTimeout, strIndexOf } from "@nevware21/ts-utils"; -import { IAnalyticsConfig } from "./Interfaces/IAnalyticsConfig"; +import { IAnalyticsConfig, eRouteTraceStrategy } from "./Interfaces/IAnalyticsConfig"; import { IAppInsightsInternal, IPageViewManager, createPageViewManager } from "./Telemetry/PageViewManager"; import { IPageViewPerformanceManager, createPageViewPerformanceManager } from "./Telemetry/PageViewPerformanceManager"; import { IPageVisitTimeManager, createPageVisitTimeManager } from "./Telemetry/PageVisitTimeManager"; @@ -68,7 +69,8 @@ const defaultValues: IConfigDefaults = objDeepFreeze({ enableDebug: cfgDfBoolean(), disableFlushOnBeforeUnload: cfgDfBoolean(), disableFlushOnUnload: cfgDfBoolean(false, "disableFlushOnBeforeUnload"), - expCfg: cfgDfMerge({inclScripts: false, expLog: undefined, maxLogs: 50}) + expCfg: cfgDfMerge({inclScripts: false, expLog: undefined, maxLogs: 50}), + routeTraceStrategy: eRouteTraceStrategy.Server }); function _chkConfigMilliseconds(value: number, defValue: number): number { @@ -124,6 +126,15 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights let _extConfig: IAnalyticsConfig; let _autoTrackPageVisitTime: boolean; let _expCfg: IExceptionConfig; + + // New configuration variables for trace context management + let _routeTraceStrategy: eRouteTraceStrategy; + + // Counts number of trackAjax invocations. + // By default we only monitor X ajax call per view to avoid too much load. + // Default value is set in config. + // This counter keeps increasing even after the limit is reached. + let _trackAjaxAttempts: number = 0; // array with max length of 2 that store current url and previous url for SPA page route change trackPageview use. let _prevUri: string; // Assigned in the constructor @@ -149,8 +160,8 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights try { let telemetryItem = createTelemetryItem( event, - EventTelemetry.dataType, - EventTelemetry.envelopeType, + EventDataType, + EventEnvelopeType, _self.diagLog(), customProperties ); @@ -205,8 +216,8 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights try { let telemetryItem = createTelemetryItem( trace, - Trace.dataType, - Trace.envelopeType, + TraceDataType, + TraceEnvelopeType, _self.diagLog(), customProperties); @@ -233,8 +244,8 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights try { let telemetryItem = createTelemetryItem( metric, - Metric.dataType, - Metric.envelopeType, + MetricDataType, + MetricEnvelopeType, _self.diagLog(), customProperties ); @@ -295,8 +306,8 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights } let telemetryItem = createTelemetryItem( pageView, - PageView.dataType, - PageView.envelopeType, + PageViewDataType, + PageViewEnvelopeType, _self.diagLog(), properties, systemProperties); @@ -314,8 +325,8 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights _self.sendPageViewPerformanceInternal = (pageViewPerformance: IPageViewPerformanceTelemetryInternal, properties?: { [key: string]: any }, systemProperties?: { [key: string]: any }) => { let telemetryItem = createTelemetryItem( pageViewPerformance, - PageViewPerformance.dataType, - PageViewPerformance.envelopeType, + PageViewPerformanceDataType, + PageViewPerformanceEnvelopeType, _self.diagLog(), properties, systemProperties); @@ -440,8 +451,8 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights } let telemetryItem: ITelemetryItem = createTelemetryItem( exceptionPartB, - Exception.dataType, - Exception.envelopeType, + ExceptionDataType, + ExceptionEnvelopeType, _self.diagLog(), customProperties, systemProperties @@ -653,6 +664,9 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights _expCfg = _extConfig.expCfg; _autoTrackPageVisitTime = _extConfig.autoTrackPageVisitTime; + // Initialize new trace context configuration options + _routeTraceStrategy = _extConfig.routeTraceStrategy || eRouteTraceStrategy.Server; + if (config.storagePrefix){ utlSetStoragePrefix(config.storagePrefix); } @@ -684,7 +698,7 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights if (!_browserLinkInitializerAdded && _isBrowserLinkTrackingEnabled) { const browserLinkPaths = ["/browserLinkSignalR/", "/__browserLink/"]; const dropBrowserLinkRequests = (envelope: ITelemetryItem) => { - if (_isBrowserLinkTrackingEnabled && envelope.baseType === RemoteDependencyData.dataType) { + if (_isBrowserLinkTrackingEnabled && envelope.baseType === RemoteDependencyDataType) { let remoteData = envelope.baseData as IDependencyTelemetry; if (remoteData) { for (let i = 0; i < browserLinkPaths.length; i++) { @@ -706,8 +720,8 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights function _sendCORSException(exception: IAutoExceptionTelemetry, properties?: ICustomProperties) { let telemetryItem: ITelemetryItem = createTelemetryItem( exception, - Exception.dataType, - Exception.envelopeType, + ExceptionDataType, + ExceptionEnvelopeType, _self.diagLog(), properties ); @@ -795,24 +809,37 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights if (_self.core && _self.core.config) { _currUri = fieldRedaction(_currUri, _self.core.config); } + if (_enableAutoRouteTracking) { // TODO(OTelSpan) (create new "context") / spans for the new page view // Should "end" any previous span (once we have a new one) - let newContext = _self.core.getTraceCtx(true); - // While the above will create a new context instance it doesn't generate a new traceId - // so we need to generate a new one here - newContext.setTraceId(generateW3CId()); + let newContext: IDistributedTraceContext; - // This populates the ai.operation.name which has a maximum size of 1024 so we need to sanitize it - newContext.pageName = dataSanitizeString(_self.diagLog(), newContext.pageName || "_unknown_"); + // Quick and dirty backward compatibility check -- should never be needed but here to avoid a JS exception if (_self.core && _self.core.getTraceCtx) { + let currentContext = _self.core.getTraceCtx(false); // Get current context without creating new + + if (currentContext && _routeTraceStrategy === eRouteTraceStrategy.Page) { + // Create new context with the determined parent + newContext = createDistributedTraceContext(currentContext); + } else { + // Fall back to original behavior - use server context as parent + newContext = _self.core.getTraceCtx(true); + } + + // Always generate new trace ID for route changes (this also generates new span ID) + newContext.traceId = generateW3CId(); + + // This populates the ai.operation.name which has a maximum size of 1024 so we need to sanitize it + newContext.pageName = dataSanitizeString(_self.diagLog(), newContext.pageName || "_unknown_"); + _self.core.setTraceCtx(newContext); } + // Single page view tracking call for all scenarios scheduleTimeout(((uri: string) => { - // todo: override start time so that it is not affected by autoRoutePVDelay - _self.trackPageView({ refUri: uri, properties: { duration: 0 } }); // SPA route change loading durations are undefined, so send 0 + _self.trackPageView({ refUri: uri, properties: { duration: 0 } }); }).bind(_self, _prevUri), _self.autoRoutePVDelay); } } diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Interfaces/IAnalyticsConfig.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Interfaces/IAnalyticsConfig.ts index e0c00ef29..2f9a3d36b 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Interfaces/IAnalyticsConfig.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Interfaces/IAnalyticsConfig.ts @@ -3,6 +3,29 @@ import { IExceptionConfig } from "@microsoft/applicationinsights-core-js"; +/** + * Enum values for configuring trace context strategy for SPA route changes. + * Controls how trace contexts are managed when navigating between pages in a Single Page Application. + * @since 3.4.0 + */ +export const enum eRouteTraceStrategy { + /** + * Server strategy: Each page view gets a new, independent trace context. + * No parent-child relationships are created between page views. + * Each page will use the original server-provided trace context (if available) as its parent, + * as defined by the {@link IConfiguration.traceHdrMode} configuration for distributed tracing headers. + * This is the traditional behavior where each page view is treated as a separate operation. + */ + Server = 0, + + /** + * Page strategy: Page views are chained together with parent-child relationships. + * Each new page view inherits the trace context from the previous page view, + * creating a connected chain of related operations for better correlation. + */ + Page = 1 +} + /** * Configuration interface specifically for AnalyticsPlugin * This interface defines only the configuration properties that the Analytics plugin uses. @@ -115,5 +138,14 @@ export interface IAnalyticsConfig { * @default { inclScripts: false, expLog: undefined, maxLogs: 50 } */ expCfg?: IExceptionConfig; + + /** + * Controls the trace context strategy for SPA route changes. + * Determines how trace contexts are managed and correlated across virtual page views + * in Single Page Applications, affecting telemetry correlation and operation tracking. + * @default eRouteTraceStrategy.Server + * @since 3.4.0 + */ + routeTraceStrategy?: eRouteTraceStrategy; } diff --git a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts index ad567a8c5..4bb519311 100644 --- a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts +++ b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts @@ -1,6 +1,6 @@ import { AITestClass, Assert } from "@microsoft/ai-test-framework"; import { NonOverrideCfg } from "../../../src/Interfaces/ICfgSyncConfig"; -import { ICookieMgrConfig, AppInsightsCore, CdnFeatureMode, FeatureOptInMode, IAppInsightsCore, IConfiguration, IFeatureOptIn, IFeatureOptInDetails, INotificationManager, IPlugin, ITelemetryItem, PerfManager } from "@microsoft/applicationinsights-core-js"; +import { ICookieMgrConfig, AppInsightsCore, CdnFeatureMode, FeatureOptInMode, IAppInsightsCore, IConfiguration, IFeatureOptIn, IFeatureOptInDetails, INotificationManager, IPlugin, ITelemetryItem, PerfManager, suppressTracing } from "@microsoft/applicationinsights-core-js"; import { IConfig, IStorageBuffer } from "@microsoft/applicationinsights-common"; import { resolveCdnFeatureCfg, replaceByNonOverrideCfg, applyCdnfeatureCfg } from "../../../src/CfgSyncHelperFuncs"; import { ICfgSyncCdnConfig } from "../../../src/Interfaces/ICfgSyncCdnConfig"; @@ -107,9 +107,24 @@ export class CfgSyncHelperTests extends AITestClass { // } //}, traceHdrMode: 3, + traceCfg: { + generalLimits: { + attributeCountLimit: 128 + }, + // spanLimits: { + // attributeCountLimit: 128, + // linkCountLimit: 128, + // eventCountLimit: 128, + // attributePerEventCountLimit: 128, + // attributePerLinkCountLimit: 128 + // }, + serviceName: null, + suppressTracing: false + }, + errorHandlers: {}, enableDebug: false - } - + }; + let core = new AppInsightsCore(); this.onDone(() => { core.unload(false); @@ -124,7 +139,6 @@ export class CfgSyncHelperTests extends AITestClass { this.clock.tick(1); coreCfg = core.config; Assert.deepEqual(JSON.stringify(coreCfg), JSON.stringify(expectedCoreCfg), "core config should be updated as expected"); - } }); diff --git a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsyncplugin.tests.ts b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsyncplugin.tests.ts index 80e32e0bf..9e94cb4a1 100644 --- a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsyncplugin.tests.ts +++ b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsyncplugin.tests.ts @@ -387,7 +387,7 @@ export class CfgSyncPluginTests extends AITestClass { this.testCaseAsync({ name: "CfgSyncPlugin: should fetch from config url at expected interval", - stepDelay: 10, + stepDelay: 100, useFakeTimers: true, steps: [ () => { let doc = getGlobal(); diff --git a/extensions/applicationinsights-dependencies-js/Tests/Unit/src/ajax.tests.ts b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/ajax.tests.ts index 171831404..a3d6db09b 100644 --- a/extensions/applicationinsights-dependencies-js/Tests/Unit/src/ajax.tests.ts +++ b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/ajax.tests.ts @@ -2114,7 +2114,7 @@ export class AjaxTests extends AITestClass { method: 'get', headers: headers }; - const url = 'https://httpbin.org/status/200'; + const url = 'http://localhost:9001/shared/'; let headerSpy = this.sandbox.spy(this._ajax, "includeCorrelationHeaders"); @@ -2794,7 +2794,7 @@ export class AjaxTests extends AITestClass { statusText: "Hello", trailer: null, type: "basic", - url: "https://httpbin.org/status/200", + url: "http://localhost:9001/shared/", clone: () => null, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), blob: () => Promise.resolve(new Blob()), @@ -2824,7 +2824,7 @@ export class AjaxTests extends AITestClass { appInsightsCore.initialize(coreConfig, [this._ajax, new TestChannelPlugin()]); // Use test hook to simulate the correct url location host to enable correlation headers - this._ajax["_currentWindowHost"] = "httpbin.org"; + this._ajax["_currentWindowHost"] = "localhost:9001"; // Set up trace context with known values let traceCtx = appInsightsCore.getTraceCtx(); @@ -2858,13 +2858,13 @@ export class AjaxTests extends AITestClass { try { // Act - make first fetch request (this should trigger header addition) - fetch("https://httpbin.org/status/200", { + fetch("http://localhost:9001/shared/", { method: "GET", headers: { "Custom-Header": "Value1" } }); // Act - make second fetch request - fetch("https://httpbin.org/api/test", { + fetch("https://localhost:9001/api/test", { method: "POST", headers: { "Custom-Header": "Value2" } }); @@ -3753,6 +3753,10 @@ export class AjaxPerfTrackTests extends AITestClass { appInsightsCore.unload(false); }); + this.onDone(() => { + appInsightsCore.unload(false); + }); + // Used to "wait" for App Insights to finish initializing which should complete after the XHR request this._context["trackStub"] = this.sandbox.stub(appInsightsCore, "track"); @@ -3817,6 +3821,10 @@ export class AjaxPerfTrackTests extends AITestClass { appInsightsCore.unload(false); }); + this.onDone(() => { + appInsightsCore.unload(false); + }); + // Used to "wait" for App Insights to finish initializing which should complete after the XHR request this._context["trackStub"] = this.sandbox.stub(appInsightsCore, "track"); @@ -3983,6 +3991,10 @@ export class AjaxPerfTrackTests extends AITestClass { appInsightsCore.unload(false); }); + this.onDone(() => { + appInsightsCore.unload(false); + }); + // Used to "wait" for App Insights to finish initializing which should complete after the XHR request this._context["trackStub"] = this.sandbox.stub(appInsightsCore, "track"); @@ -4044,6 +4056,10 @@ export class AjaxPerfTrackTests extends AITestClass { appInsightsCore.unload(false); }); + this.onDone(() => { + appInsightsCore.unload(false); + }); + let trackSpy = this.sandbox.spy(appInsightsCore, "track"); this._context["trackStub"] = trackSpy; return this._asyncQueue() @@ -4436,7 +4452,7 @@ export class AjaxFrozenTests extends AITestClass { // testThis._context["_eventsSent"] = events; // } // }); - // this._ajax["_currentWindowHost"] = "httpbin.org"; + // this._ajax["_currentWindowHost"] = "localhost:9001"; // // Used to "wait" for App Insights to finish initializing which should complete after the XHR request // this._context["trackStub"] = this.sandbox.stub(appInsightsCore, "track"); @@ -4452,7 +4468,7 @@ export class AjaxFrozenTests extends AITestClass { // } // // trigger the request that should cause a track event once the xhr request is complete - // xhr.open("GET", "https://httpbin.org/status/200"); + // xhr.open("GET", "http://localhost:9001/shared/"); // xhr.send(); // appInsightsCore.track({ // name: "Hello World!" diff --git a/extensions/applicationinsights-dependencies-js/src/ajax.ts b/extensions/applicationinsights-dependencies-js/src/ajax.ts index 96df6d0b0..160c23d3a 100644 --- a/extensions/applicationinsights-dependencies-js/src/ajax.ts +++ b/extensions/applicationinsights-dependencies-js/src/ajax.ts @@ -4,9 +4,9 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { DisabledPropertyName, IConfig, ICorrelationConfig, IDependencyTelemetry, IRequestContext, ITelemetryContext, PropertiesPluginIdentifier, - RemoteDependencyData, RequestHeaders, correlationIdCanIncludeCorrelationHeader, correlationIdGetCorrelationContext, - createDistributedTraceContextFromTrace, createTelemetryItem, createTraceParent, dateTimeUtilsNow, eDistributedTracingModes, - eRequestHeaders, formatTraceParent, isInternalApplicationInsightsEndpoint + RemoteDependencyDataType, RemoteDependencyEnvelopeType, RequestHeaders, correlationIdCanIncludeCorrelationHeader, + correlationIdGetCorrelationContext, createDistributedTraceContextFromTrace, createTelemetryItem, createTraceParent, dateTimeUtilsNow, + eDistributedTracingModes, eRequestHeaders, formatTraceParent, isInternalApplicationInsightsEndpoint } from "@microsoft/applicationinsights-common"; import { BaseTelemetryPlugin, IAppInsightsCore, IConfigDefaults, IConfiguration, ICustomProperties, IDistributedTraceContext, @@ -335,8 +335,6 @@ export interface IDependenciesPlugin extends IDependencyListenerContainer { /** * Interface for ajax data passed to includeCorrelationHeaders function. * Contains the public properties and methods needed for correlation header processing. - * - * @public */ export interface IAjaxRecordData { /** @@ -773,8 +771,8 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IAjaxMonitorPlug } const item = createTelemetryItem( dependency, - RemoteDependencyData.dataType, - RemoteDependencyData.envelopeType, + RemoteDependencyDataType, + RemoteDependencyEnvelopeType, _self[strDiagLog](), properties, systemProperties); diff --git a/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts b/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts index 25b1c829e..73a194492 100644 --- a/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts +++ b/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts @@ -6,4 +6,4 @@ export { } from "./ajax"; export { IDependencyHandler, IDependencyListenerHandler, IDependencyListenerDetails, DependencyListenerFunction } from "./DependencyListener"; export { IDependencyInitializerHandler, IDependencyInitializerDetails, DependencyInitializerFunction } from "./DependencyInitializer"; -export { ICorrelationConfig } from "@microsoft/applicationinsights-common"; +export { ICorrelationConfig, eDistributedTracingModes, DistributedTracingModes } from "@microsoft/applicationinsights-common"; diff --git a/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts b/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts index 4c971dfca..2efd95fe5 100644 --- a/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts +++ b/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts @@ -5,7 +5,7 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { - BreezeChannelIdentifier, IConfig, IPropertiesPlugin, PageView, PropertiesPluginIdentifier, utlSetStoragePrefix + BreezeChannelIdentifier, IConfig, IPropertiesPlugin, PageView, PageViewEnvelopeType, PropertiesPluginIdentifier, utlSetStoragePrefix } from "@microsoft/applicationinsights-common"; import { BaseTelemetryPlugin, IAppInsightsCore, IConfigDefaults, IConfiguration, IPlugin, IProcessTelemetryContext, @@ -74,7 +74,7 @@ export default class PropertiesPlugin extends BaseTelemetryPlugin implements IPr if (!isNullOrUndefined(event)) { itemCtx = _self._getTelCtx(itemCtx); // If the envelope is PageView, reset the internal message count so that we can send internal telemetry for the new page. - if (event.name === PageView.envelopeType) { + if (event.name === PageViewEnvelopeType) { itemCtx.diagLog().resetInternalMessageCount(); } diff --git a/extensions/applicationinsights-properties-js/src/TelemetryContext.ts b/extensions/applicationinsights-properties-js/src/TelemetryContext.ts index 3ac777c5b..a740d7705 100644 --- a/extensions/applicationinsights-properties-js/src/TelemetryContext.ts +++ b/extensions/applicationinsights-properties-js/src/TelemetryContext.ts @@ -6,15 +6,13 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { CtxTagKeys, Extensions, IApplication, IDevice, IInternal, ILocation, IOperatingSystem, ISession, ISessionManager, ITelemetryTrace, - IUserContext, IWeb, PageView, dataSanitizeString + IUserContext, IWeb, PageViewDataType, dataSanitizeString } from "@microsoft/applicationinsights-common"; import { IAppInsightsCore, IDistributedTraceContext, IProcessTelemetryContext, ITelemetryItem, IUnloadHookContainer, _InternalLogMessage, getSetValue, hasWindow, isNullOrUndefined, isString, objKeys, setValue } from "@microsoft/applicationinsights-core-js"; -import { - createDeferredCachedValue, fnCall, isFunction, isUndefined, objDefine, objDefineProps, strLetterCase -} from "@nevware21/ts-utils"; +import { fnCall, getDeferred, isFunction, isUndefined, objDefine, objDefineProps, strLetterCase } from "@nevware21/ts-utils"; import { Application } from "./Context/Application"; import { Device } from "./Context/Device"; import { Internal } from "./Context/Internal"; @@ -38,18 +36,31 @@ function _nullResult(): string { return null; } +/** + * Create a telemetryTrace object that will be used to manage the trace context for the current telemetry item. + * This will create a proxy object that will read the values from the core.getTraceCtx() and provide a way to update the values + * in the core.getTraceCtx() if they are valid. + * @param core - The core instance that will be used to get the trace context + * @returns A telemetryTrace object that will be used to manage the trace context for the current telemetry item. + */ function _createTelemetryTrace(core: IAppInsightsCore): ITelemetryTrace { let coreTraceCtx: IDistributedTraceContext | null = core ? core.getTraceCtx() : null; let trace: any = {}; - function _getTraceCtx(name: keyof IDistributedTraceContext extends string ? keyof IDistributedTraceContext : never): T { - let value: T; + function _getCtx() { let ctx = core ? core.getTraceCtx() : null; if (coreTraceCtx && ctx !== coreTraceCtx) { // It appears that the coreTraceCtx has been updated, so clear the local trace context trace = {}; } + return ctx; + } + + function _getTraceCtx(name: keyof IDistributedTraceContext extends string ? keyof IDistributedTraceContext : never): T { + let value: T; + let ctx = _getCtx(); + if (!isUndefined(trace[name])) { // has local value value = trace[name]; @@ -74,11 +85,7 @@ function _createTelemetryTrace(core: IAppInsightsCore): ITelemetryTrace { } function _setTraceCtx(name: keyof IDistributedTraceContext extends string ? keyof IDistributedTraceContext : never, value: V, checkFn?: () => V) { - let ctx = core ? core.getTraceCtx() : null; - if (coreTraceCtx && ctx !== coreTraceCtx) { - // It appears that the coreTraceCtx has been updated, so clear the local trace context - trace = {}; - } + let ctx = _getCtx(); if (ctx) { if (name in ctx) { @@ -115,7 +122,16 @@ function _createTelemetryTrace(core: IAppInsightsCore): ITelemetryTrace { } function _getParentId() { - return _getTraceCtx("spanId"); + let ctx = _getCtx(); + let spanId = trace["spanId"]; + if (ctx && isUndefined(spanId)) { + let parentCtx = ctx.parentCtx; + if (parentCtx) { + spanId = parentCtx.spanId; + } + } + + return spanId || _getTraceCtx("spanId"); } function _getTraceFlags() { @@ -193,7 +209,7 @@ export class TelemetryContext implements IPropTelemetryContext { _self.session = new Session(); objDefine(_self, "telemetryTrace", { - l: createDeferredCachedValue(() => _createTelemetryTrace(core)) + l: getDeferred(() => _createTelemetryTrace(core)) }); } @@ -252,7 +268,7 @@ export class TelemetryContext implements IPropTelemetryContext { setValue(tags, CtxTagKeys.internalAgentVersion, internal.agentVersion, isString); // not mapped in CS 4.0 setValue(tags, CtxTagKeys.internalSdkVersion, dataSanitizeString(logger, internal.sdkVersion, 64), isString); - if (evt.baseType === _InternalLogMessage.dataType || evt.baseType === PageView.dataType) { + if (evt.baseType === _InternalLogMessage.dataType || evt.baseType === PageViewDataType) { setValue(tags, CtxTagKeys.internalSnippet, internal.snippetVer, isString); setValue(tags, CtxTagKeys.internalSdkSrc, internal.sdkSrc, isString); } diff --git a/gruntfile.js b/gruntfile.js index 6b2278622..0e57024b4 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -573,6 +573,12 @@ module.exports = function (grunt) { path: "./examples/cfgSync", testHttp: false }, + + "example-startspan": { + autoMinify: false, + path: "./examples/startSpan", + testHttp: false + }, // Tools "rollupuglify": { @@ -981,6 +987,7 @@ module.exports = function (grunt) { grunt.registerTask("example-aisku", tsBuildActions("example-aisku")); grunt.registerTask("example-dependency", tsBuildActions("example-dependency")); grunt.registerTask("example-cfgsync", tsBuildActions("example-cfgsync")); + grunt.registerTask("example-startspan", tsBuildActions("example-startspan")); // Register the lint-fix task to run ESLint fix on all packages grunt.registerTask("lint-fix", getLintFixTasks()); diff --git a/rush.json b/rush.json index b7764b1aa..f59978e29 100644 --- a/rush.json +++ b/rush.json @@ -134,6 +134,11 @@ "projectFolder": "examples/shared-worker", "shouldPublish": false }, + { + "packageName": "@microsoft/applicationinsights-example-startspan", + "projectFolder": "examples/startSpan", + "shouldPublish": false + }, { "packageName": "@microsoft/applicationinsights-test-module-type-check", "projectFolder": "AISKU/Tests/es6-module-type-check", diff --git a/shared/1ds-core-js/src/DataModels.ts b/shared/1ds-core-js/src/DataModels.ts index eec72a2e1..55a0dfafa 100644 --- a/shared/1ds-core-js/src/DataModels.ts +++ b/shared/1ds-core-js/src/DataModels.ts @@ -41,26 +41,26 @@ export interface IExtendedTelemetryItem extends ITelemetryItem { * Custom properties (alternatively referred to as Part C properties for a Common Schema event) can be * directly added under data. */ - data?: { - [key: string]: string | number | boolean | string[] | number[] | boolean[] | IEventProperty | object; - }; + data?: { [key: string]: any; }; + /** * Telemetry properties pertaining to domain about which data is being captured. Example, duration, referrerUri for browser page. * These are alternatively referred to as Part B properties for a Common Schema event. */ - baseData?: { - [key: string]: string | number | boolean | string[] | number[] | boolean[] | IEventProperty | object; - }; + baseData?: { [key: string]: any; }; + /** * An EventLatency value, that specifies the latency for the event.The EventLatency constant should be * used to specify the different latency values. */ latency?: number | EventLatencyValue; + /** * [Optional] An EventPersistence value, that specifies the persistence for the event. The EventPersistence constant * should be used to specify the different persistence values. */ persistence?: number | EventPersistenceValue; + /** * [Optional] A boolean that specifies whether the event should be sent as a sync request. */ diff --git a/shared/1ds-core-js/src/Index.ts b/shared/1ds-core-js/src/Index.ts index 29aa37a14..0ac5fef60 100644 --- a/shared/1ds-core-js/src/Index.ts +++ b/shared/1ds-core-js/src/Index.ts @@ -27,7 +27,7 @@ export { export { IAppInsightsCore, IChannelControls, IPlugin, INotificationManager, NotificationManager, INotificationListener, - IConfiguration, ITelemetryItem, ITelemetryPlugin, BaseTelemetryPlugin, IProcessTelemetryContext, ProcessTelemetryContext, ITelemetryPluginChain, + IConfiguration, ITelemetryItem, ITelemetryPlugin, BaseTelemetryPlugin, IProcessTelemetryContext, ITelemetryPluginChain, MinChannelPriorty, EventsDiscardedReason, IDiagnosticLogger, DiagnosticLogger, LoggingSeverity, SendRequestReason, IPerfEvent, IPerfManager, IPerfManagerProvider, PerfEvent, PerfManager, doPerf, ICustomProperties, Tags, AppInsightsCore as InternalAppInsightsCore, _InternalLogMessage, _InternalMessageId, eActiveStatus, ActiveStatus, @@ -67,7 +67,7 @@ export { IWatchDetails, IWatcherHandler, WatcherFunction, createDynamicConfig, onConfigChange, getDynamicConfigHandler, blockDynamicConversion, forceDynamicConversion, IPayloadData, IXHROverride, OnCompleteCallback, SendPOSTFunction, IInternalOfflineSupport, _ISendPostMgrConfig, IBackendResponse, _ISenderOnComplete, SenderPostManager, - getResponseText, formatErrorMessageXdr, formatErrorMessageXhr, prependTransports, parseResponse, convertAllHeadersToMap, _getAllResponseHeaders, _appendHeader, _IInternalXhrOverride, + getResponseText, formatErrorMessageXdr, formatErrorMessageXhr, prependTransports, parseResponse, _getAllResponseHeaders, _appendHeader, _IInternalXhrOverride, _ITimeoutOverrideWrapper, IXDomainRequest, isFeatureEnabled, FeatureOptInMode, TransportType, @@ -85,3 +85,5 @@ export { openXhr, isGreaterThanZero } from "./Utils"; + +export { createExtendedTelemetryItemFromSpan } from "./spanUtils"; \ No newline at end of file diff --git a/shared/1ds-core-js/src/InternalConstants.ts b/shared/1ds-core-js/src/InternalConstants.ts index f02e56dc1..02469d1f1 100644 --- a/shared/1ds-core-js/src/InternalConstants.ts +++ b/shared/1ds-core-js/src/InternalConstants.ts @@ -8,7 +8,9 @@ // Generally you should only put values that are used more than 2 times and then only if not already exposed as a constant (such as SdkCoreNames) // as when using "short" named values from here they will be will be minified smaller than the SdkCoreNames[eSdkCoreNames.xxxx] value. +export const UNDEFINED_VALUE: undefined = undefined; export const STR_EMPTY = ""; export const STR_DEFAULT_ENDPOINT_URL = "https://browser.events.data.microsoft.com/OneCollector/1.0/"; export const STR_VERSION = "version"; export const STR_PROPERTIES = "properties"; +export const STR_NOT_SPECIFIED = "not_specified"; diff --git a/shared/1ds-core-js/src/spanUtils.ts b/shared/1ds-core-js/src/spanUtils.ts new file mode 100644 index 000000000..b5e009271 --- /dev/null +++ b/shared/1ds-core-js/src/spanUtils.ts @@ -0,0 +1,622 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ATTR_CLIENT_ADDRESS, ATTR_CLIENT_PORT, ATTR_ENDUSER_ID, ATTR_ENDUSER_PSEUDO_ID, ATTR_ERROR_TYPE, ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, ATTR_EXCEPTION_TYPE, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_HTTP_ROUTE, + ATTR_NETWORK_LOCAL_ADDRESS, ATTR_NETWORK_LOCAL_PORT, ATTR_NETWORK_PEER_ADDRESS, ATTR_NETWORK_PEER_PORT, ATTR_NETWORK_PROTOCOL_NAME, + ATTR_NETWORK_PROTOCOL_VERSION, ATTR_NETWORK_TRANSPORT, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, ATTR_URL_PATH, + ATTR_URL_QUERY, ATTR_URL_SCHEME, ATTR_USER_AGENT_ORIGINAL, DBSYSTEMVALUES_MONGODB, DBSYSTEMVALUES_MYSQL, DBSYSTEMVALUES_POSTGRESQL, + DBSYSTEMVALUES_REDIS, EXP_ATTR_ENDUSER_ID, EXP_ATTR_ENDUSER_PSEUDO_ID, EXP_ATTR_SYNTHETIC_TYPE, IAppInsightsCore, IAttributeContainer, + IDiagnosticLogger, IReadableSpan, MicrosoftClientIp, OTelAttributeValue, SEMATTRS_DB_NAME, SEMATTRS_DB_OPERATION, SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_SYSTEM, SEMATTRS_ENDUSER_ID, SEMATTRS_EXCEPTION_MESSAGE, SEMATTRS_EXCEPTION_STACKTRACE, SEMATTRS_EXCEPTION_TYPE, + SEMATTRS_HTTP_CLIENT_IP, SEMATTRS_HTTP_FLAVOR, SEMATTRS_HTTP_HOST, SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_ROUTE, SEMATTRS_HTTP_SCHEME, + SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_HTTP_TARGET, SEMATTRS_HTTP_URL, SEMATTRS_HTTP_USER_AGENT, SEMATTRS_NET_HOST_IP, + SEMATTRS_NET_HOST_NAME, SEMATTRS_NET_HOST_PORT, SEMATTRS_NET_PEER_IP, SEMATTRS_NET_PEER_NAME, SEMATTRS_NET_PEER_PORT, + SEMATTRS_NET_TRANSPORT, SEMATTRS_PEER_SERVICE, SEMATTRS_RPC_GRPC_STATUS_CODE, SEMATTRS_RPC_SYSTEM, Tags, createAttributeContainer, + eDependencyTypes, eOTelSpanKind, eOTelSpanStatusCode, getDependencyTarget, getHttpMethod, getHttpStatusCode, getHttpUrl, getLocationIp, + getUrl, getUserAgent, hrTimeToMilliseconds, isSqlDB, isSyntheticSource, toISOString +} from "@microsoft/applicationinsights-core-js"; +import { + ILazyValue, arrIncludes, asString, getLazy, isNullOrUndefined, objForEachKey, strLower, strStartsWith, strSubstring, throwError +} from "@nevware21/ts-utils"; +import { IExtendedTelemetryItem } from "./DataModels"; +import { STR_EMPTY, STR_NOT_SPECIFIED, UNDEFINED_VALUE } from "./InternalConstants"; + +/** + * Azure SDK namespace. + * @internal + */ +const AzNamespace = "az.namespace"; + +/** + * Azure SDK Eventhub. + * @internal + */ +const MicrosoftEventHub = "Microsoft.EventHub"; + +/** + * Azure SDK message bus destination. + * @internal + */ +const MessageBusDestination = "message_bus.destination"; + +/** + * AI time since enqueued attribute. + * @internal + */ +const TIME_SINCE_ENQUEUED = "timeSinceEnqueued"; + +const PORT_REGEX: ILazyValue = (/*#__PURE__*/ getLazy(() => new RegExp(/(https?)(:\/\/.*)(:\d+)(\S*)/))); +const HTTP_DOT = (/*#__PURE__*/ "http."); + +const _MS_PROCESSED_BY_METRICS_EXTRACTORS = (/* #__PURE__*/"_MS.ProcessedByMetricExtractors"); +const enum eMaxPropertyLengths { + NINE_BIT = 512, + TEN_BIT = 1024, + THIRTEEN_BIT = 8192, + FIFTEEN_BIT = 32768, +} + +/** + * Legacy HTTP semantic convention values + * @internal + */ +const _ignoreSemanticValues: ILazyValue = (/* #__PURE__*/ getLazy(_initIgnoreSemanticValues)); + +export interface IPartC { + /** + * Property bag to contain additional custom properties (Part C) + */ + properties?: { [key: string]: any }; + + /** + * Property bag to contain additional custom measurements (Part C) + * @deprecated -- please use properties instead + */ + measurements?: { [key: string]: number }; +} + +/** + * DependencyTelemetry telemetry interface + */ +export interface IDependencyTelemetry extends IPartC { + id: string; + name?: string; + duration?: number; + success?: boolean; + startTime?: Date; + responseCode: number; + correlationContext?: string; + type?: string; + data?: string; + target?: string; + iKey?: string; + + /** + * Correlation Vector + */ + cV?: string; +} + +export interface IRequestTelemetry extends IPartC { + /** + * Identifier of a request call instance. Used for correlation between request and other telemetry items. + */ + id: string; + + /** + * Name of the request. Represents code path taken to process request. Low cardinality value to allow better grouping of requests. For HTTP requests it represents the HTTP method and URL path template like 'GET /values/\{id\}'. + */ + name?: string; + + /** + * Request duration in milliseconds. + */ + duration: number; + + /** + * Indication of successful or unsuccessful call. + */ + success: boolean; + + /** + * Result of a request execution. HTTP status code for HTTP requests. + */ + responseCode: number; + + /** + * Source of the request. Examples are the instrumentation key of the caller or the ip address of the caller. + */ + source?: string; + + /** + * Request URL with all query string parameters. + */ + url?: string; +} + +function _initIgnoreSemanticValues(): string[] { + return [ + // Internal Microsoft attributes + _MS_PROCESSED_BY_METRICS_EXTRACTORS, + + // Legacy HTTP semantic values + SEMATTRS_NET_PEER_IP, + SEMATTRS_NET_PEER_NAME, + SEMATTRS_NET_HOST_IP, + SEMATTRS_PEER_SERVICE, + SEMATTRS_HTTP_USER_AGENT, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_URL, + SEMATTRS_HTTP_STATUS_CODE, + SEMATTRS_HTTP_ROUTE, + SEMATTRS_HTTP_HOST, + SEMATTRS_DB_SYSTEM, + SEMATTRS_DB_STATEMENT, + SEMATTRS_DB_OPERATION, + SEMATTRS_DB_NAME, + SEMATTRS_RPC_SYSTEM, + SEMATTRS_RPC_GRPC_STATUS_CODE, + SEMATTRS_EXCEPTION_TYPE, + SEMATTRS_EXCEPTION_MESSAGE, + SEMATTRS_EXCEPTION_STACKTRACE, + SEMATTRS_HTTP_SCHEME, + SEMATTRS_HTTP_TARGET, + SEMATTRS_HTTP_FLAVOR, + SEMATTRS_NET_TRANSPORT, + SEMATTRS_NET_HOST_NAME, + SEMATTRS_NET_HOST_PORT, + SEMATTRS_NET_PEER_PORT, + SEMATTRS_HTTP_CLIENT_IP, + SEMATTRS_ENDUSER_ID, + HTTP_DOT + "status_text", + + // http Semabtic conventions + ATTR_CLIENT_ADDRESS, + ATTR_CLIENT_PORT, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, + ATTR_URL_FULL, + ATTR_URL_PATH, + ATTR_URL_QUERY, + ATTR_URL_SCHEME, + ATTR_ERROR_TYPE, + ATTR_NETWORK_LOCAL_ADDRESS, + ATTR_NETWORK_LOCAL_PORT, + ATTR_NETWORK_PROTOCOL_NAME, + ATTR_NETWORK_PEER_ADDRESS, + ATTR_NETWORK_PEER_PORT, + ATTR_NETWORK_PROTOCOL_VERSION, + ATTR_NETWORK_TRANSPORT, + ATTR_USER_AGENT_ORIGINAL, + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_RESPONSE_STATUS_CODE, + ATTR_EXCEPTION_TYPE, + ATTR_EXCEPTION_MESSAGE, + ATTR_EXCEPTION_STACKTRACE, + EXP_ATTR_ENDUSER_ID, + EXP_ATTR_ENDUSER_PSEUDO_ID, + EXP_ATTR_SYNTHETIC_TYPE + ]; +} + +// function _populateTagsFromSpan(telemetryItem: IExtendedTelemetryItem, span: IReadableSpan): void { + +// let tags: Tags = telemetryItem.tags = (telemetryItem.tags || [] as Tags); +// let container = span.attribContainer || createAttributeContainer(span.attributes); + +// tags[contextKeys.operationId] = span.spanContext().traceId; +// if (span.parentSpanContext?.spanId) { +// tags[contextKeys.operationParentId] = span.parentSpanContext.spanId; +// } + +// // Map OpenTelemetry enduser attributes to Application Insights user attributes +// const endUserId = container.get(ATTR_ENDUSER_ID); +// if (endUserId) { +// tags[contextKeys.userAuthUserId] = asString(endUserId); +// } + +// const endUserPseudoId = container.get(ATTR_ENDUSER_PSEUDO_ID); +// if (endUserPseudoId) { +// tags[contextKeys.userId] = asString(endUserPseudoId); +// } + +// const httpUserAgent = getUserAgent(container); +// if (httpUserAgent) { +// // TODO: Not exposed in Swagger, need to update def +// tags["ai.user.userAgent"] = String(httpUserAgent); +// } +// if (isSyntheticSource(container)) { +// tags[contextKeys.operationSyntheticSource] = "True"; +// } + +// // Check for microsoft.client.ip first - this takes precedence over all other IP logic +// const microsoftClientIp = container.get(MicrosoftClientIp); +// if (microsoftClientIp) { +// tags[contextKeys.locationIp] = asString(microsoftClientIp); +// } + +// if (span.kind === eOTelSpanKind.SERVER) { +// const httpMethod = getHttpMethod(container); +// // Only use the fallback IP logic for server spans if microsoft.client.ip is not set +// if (!microsoftClientIp) { +// tags[contextKeys.locationIp] = getLocationIp(container); +// } + +// if (httpMethod) { +// const httpRoute = container.get(ATTR_HTTP_ROUTE); +// const httpUrl = getHttpUrl(container); +// tags[contextKeys.operationName] = span.name; // Default +// if (httpRoute) { +// // AiOperationName max length is 1024 +// // https://github.com/MohanGsk/ApplicationInsights-Home/blob/master/EndpointSpecs/Schemas/Bond/ContextTagKeys.bond +// tags[contextKeys.operationName] = strSubstring(httpMethod + " " + httpRoute, 0, eMaxPropertyLengths.TEN_BIT); +// } else if (httpUrl) { +// try { +// const urlPathName = urlGetPathName(asString(httpUrl)); +// tags[contextKeys.operationName] = strSubstring(httpMethod + " " + urlPathName, 0, eMaxPropertyLengths.TEN_BIT); +// } catch { +// /* no-op */ +// } +// } +// } else { +// tags[contextKeys.operationName] = span.name; +// } +// } else { +// let opName = container.get(contextKeys.operationName); +// if (opName) { +// tags[contextKeys.operationName] = opName as string; +// } +// } +// // TODO: Location IP TBD for non server spans +// } + +/** + * Check to see if the key is in the list of known properties to ignore (exclude) + * from the properties collection + * @param key - the property key to check + * @param contextKeys - The current context keys + * @returns true if the key should be ignored, false otherwise + */ +function _isIgnorePropertiesKey(key: string): boolean { + let result = false; + + if (arrIncludes(_ignoreSemanticValues.v, key)) { + // The key is in set of known keys to ignore + result = true; + } else if (strStartsWith(key, "microsoft.")) { + // Ignoring all ALL keys starting with "microsoft." + result = true; + } + + return result; +} + +function _populatePropertiesFromAttributes(item: IExtendedTelemetryItem, container: IAttributeContainer): void { + if (container) { + let baseData = item.baseData = (item.baseData || {}); + let properties: { [key: string]: any } = baseData.properties = (baseData.properties || {}); + + container.forEach((key: string, value) => { + // Avoid duplication ignoring fields already mapped. + if (!_isIgnorePropertiesKey(key)) { + properties[key] = value; + } + }); + } +} + +function _populateHttpDependencyProperties(dependencyTelemetry: IDependencyTelemetry, container: IAttributeContainer, httpMethod: OTelAttributeValue | undefined): boolean { + if (httpMethod) { + // HTTP Dependency + const httpUrl = getHttpUrl(container); + if (httpUrl) { + try { + const dependencyUrl = new URL(String(httpUrl)); + dependencyTelemetry.name = httpMethod + " " + dependencyUrl.pathname; + } catch { + /* no-op */ + } + } + + dependencyTelemetry.type = eDependencyTypes.Http; + dependencyTelemetry.data = getUrl(container); + const httpStatusCode = getHttpStatusCode(container); + if (httpStatusCode) { + dependencyTelemetry.responseCode = +httpStatusCode; + } + + let target = getDependencyTarget(container); + if (target) { + try { + // Remove default port + const res = PORT_REGEX.v.exec(target); + if (res !== null) { + const protocol = res[1]; + const port = res[3]; + if ( + (protocol === "https" && port === ":443") || + (protocol === "http" && port === ":80") + ) { + // Drop port + target = res[1] + res[2] + res[4]; + } + } + } catch { + /* no-op */ + } + + dependencyTelemetry.target = target; + } + } + + return !!httpMethod; +} + +function _populateDbDependencyProperties(dependencyTelemetry: IDependencyTelemetry, container: IAttributeContainer, dbSystem: OTelAttributeValue | undefined): boolean { + if (dbSystem) { + // TODO: Remove special logic when Azure UX supports OpenTelemetry dbSystem + if (String(dbSystem) === DBSYSTEMVALUES_MYSQL) { + dependencyTelemetry.type = "mysql"; + } else if (String(dbSystem) === DBSYSTEMVALUES_POSTGRESQL) { + dependencyTelemetry.type = "postgresql"; + } else if (String(dbSystem) === DBSYSTEMVALUES_MONGODB) { + dependencyTelemetry.type = "mongodb"; + } else if (String(dbSystem) === DBSYSTEMVALUES_REDIS) { + dependencyTelemetry.type = "redis"; + } else if (isSqlDB(String(dbSystem))) { + dependencyTelemetry.type = "SQL"; + } else { + dependencyTelemetry.type = String(dbSystem); + } + const dbStatement = container.get(SEMATTRS_DB_STATEMENT); + const dbOperation = container.get(SEMATTRS_DB_OPERATION); + if (dbStatement) { + dependencyTelemetry.data = String(dbStatement); + } else if (dbOperation) { + dependencyTelemetry.data = String(dbOperation); + } + const target = getDependencyTarget(container); + const dbName = container.get(SEMATTRS_DB_NAME); + if (target) { + dependencyTelemetry.target = dbName ? `${target}|${dbName}` : `${target}`; + } else { + dependencyTelemetry.target = dbName ? `${dbName}` : `${dbSystem}`; + } + } + + return !!dbSystem; +} + +function _populateRpcDependencyProperties(dependencyTelemetry: IDependencyTelemetry, container: IAttributeContainer, rpcSystem: OTelAttributeValue | undefined): boolean { + if (rpcSystem) { + if (strLower(rpcSystem) === "wcf") { + dependencyTelemetry.type = eDependencyTypes.Wcf; + } else { + dependencyTelemetry.type = eDependencyTypes.Grpc; + } + const grpcStatusCode = container.get(SEMATTRS_RPC_GRPC_STATUS_CODE); + if (grpcStatusCode) { + dependencyTelemetry.responseCode = +grpcStatusCode; + } + const target = getDependencyTarget(container); + if (target) { + dependencyTelemetry.target = `${target}`; + } else { + dependencyTelemetry.target = String(rpcSystem); + } + } + + return !!rpcSystem; +} + +function _createDependencyTelemetryItem(core: IAppInsightsCore, span: IReadableSpan): IExtendedTelemetryItem { + let container = span.attribContainer || createAttributeContainer(span.attributes); + let dependencyType = "Dependency"; + + if (span.kind === eOTelSpanKind.PRODUCER) { + dependencyType = eDependencyTypes.QueueMessage; + } else if (span.kind === eOTelSpanKind.INTERNAL && span.parentSpanContext) { + dependencyType = eDependencyTypes.InProc; + } + + let spanCtx = span.spanContext(); + let dependencyTelemetry: IDependencyTelemetry = { + name: span.name, // Default + id: spanCtx.spanId || core.getTraceCtx().spanId, + success: span.status?.code !== eOTelSpanStatusCode.ERROR, + responseCode: 0, + type: dependencyType, + duration: hrTimeToMilliseconds(span.duration), + data: STR_EMPTY, + target: STR_EMPTY, + properties: UNDEFINED_VALUE, + measurements: UNDEFINED_VALUE + }; + + // Check for HTTP Dependency + if (!_populateHttpDependencyProperties(dependencyTelemetry, container, getHttpMethod(container))) { + // Check for DB Dependency + if (!_populateDbDependencyProperties(dependencyTelemetry, container, container.get(SEMATTRS_DB_SYSTEM))) { + // Check for Rpc Dependency + _populateRpcDependencyProperties(dependencyTelemetry, container, container.get(SEMATTRS_RPC_SYSTEM)); + } + } + + return _createTelemetryItem(dependencyTelemetry, "RemoteDependencyData", "Ms.Web.OutgoingRequest", core.logger); +} + +function _createRequestTelemetryItem(core: IAppInsightsCore, span: IReadableSpan): IExtendedTelemetryItem { + let container = span.attribContainer || createAttributeContainer(span.attributes); + + let spanCtx = span.spanContext(); + const requestData: IRequestTelemetry = { + name: span.name, // Default + id: spanCtx.spanId || core.getTraceCtx().spanId, + success: + span.status.code !== eOTelSpanStatusCode.UNSET + ? span.status.code === eOTelSpanStatusCode.OK + : (Number(getHttpStatusCode(container)) || 0) < 400, + responseCode: 0, + duration: hrTimeToMilliseconds(span.duration), + source: undefined + }; + const httpMethod = getHttpMethod(container); + const grpcStatusCode = container.get(SEMATTRS_RPC_GRPC_STATUS_CODE); + if (httpMethod) { + requestData.url = getUrl(container); + const httpStatusCode = getHttpStatusCode(container); + if (httpStatusCode) { + requestData.responseCode = +httpStatusCode; + } + } else if (grpcStatusCode) { + requestData.responseCode = +grpcStatusCode; + } + + return _createTelemetryItem(requestData, "RequestData", "Ms.Web.Request", core.logger); +} + +function _createTelemetryItem(item: T, + baseType: string, + eventName: string, + logger: IDiagnosticLogger, + customProperties?: { [key: string]: any }, + systemProperties?: { [key: string]: any }): IExtendedTelemetryItem { + + eventName = eventName || STR_NOT_SPECIFIED; + + if (isNullOrUndefined(item) || + isNullOrUndefined(baseType) || + isNullOrUndefined(eventName)) { + throwError("Input doesn't contain all required fields"); + } + + let iKey = ""; + if ((item as any).iKey) { + iKey = (item as any).iKey; + delete (item as any).iKey; + } + + const telemetryItem: IExtendedTelemetryItem = { + name: eventName, + time: toISOString(new Date()), + iKey: iKey, // this will be set in TelemetryContext + ext: systemProperties ? systemProperties : {}, // part A + tags: [], + data: { + }, + baseType, + baseData: item as any // Part B + }; + + // Part C + if (!isNullOrUndefined(customProperties)) { + objForEachKey(customProperties, (prop, value) => { + telemetryItem.data[prop] = value; + }); + } + + return telemetryItem; +} + +/** + * Implementation of Mapping to Azure Monitor + * + * https://gist.github.com/lmolkova/e4215c0f44a49ef824983382762e6b92#mapping-to-azure-monitor-application-insights-telemetry + * @internal + */ +function _parseEventHubSpan(telemetryItem: IExtendedTelemetryItem, span: IReadableSpan): void { + let baseData = telemetryItem.baseData = telemetryItem.baseData || {}; + let container = span.attribContainer || createAttributeContainer(span.attributes); + const namespace = container.get(AzNamespace); + const peerAddress = asString(container.get(SEMATTRS_NET_PEER_NAME) || container.get("peer.address") || "unknown").replace(/\/$/g, ""); // remove trailing "/" + const messageBusDestination = (container.get(MessageBusDestination) || "unknown") as string; + let baseType = baseData.type || ""; + let kind = span.kind; + + if (kind === eOTelSpanKind.CLIENT) { + baseType = namespace as any; + baseData.target = peerAddress + "/" + messageBusDestination; + } else if (kind === eOTelSpanKind.PRODUCER) { + baseType = "Queue Message | " + namespace; + baseData.target = peerAddress + "/" + messageBusDestination; + } else if (kind === eOTelSpanKind.CONSUMER) { + baseType = "Queue Message | " + namespace; + (baseData as any).source = peerAddress + "/" + messageBusDestination; + + let measurements = baseData.measurements = (baseData.measurements || {}); + let timeSinceEnqueued = container.get("timeSinceEnqueued"); + if (timeSinceEnqueued) { + measurements[TIME_SINCE_ENQUEUED] = Number(timeSinceEnqueued); + } else { + let enqueuedTime = parseFloat(asString(container.get("enqueuedTime"))); + if (isNaN(enqueuedTime)) { + enqueuedTime = 0; + } + + measurements[TIME_SINCE_ENQUEUED] = hrTimeToMilliseconds(span.startTime) - enqueuedTime; + } + } + + baseData.type = baseType; +} + +export function createExtendedTelemetryItemFromSpan(core: IAppInsightsCore, span: IReadableSpan): IExtendedTelemetryItem | null { + let telemetryItem: IExtendedTelemetryItem = null; + let container = span.attribContainer || createAttributeContainer(span.attributes); + // let contextKeys: IContextTagKeys = CtxTagKeys; + let kind = span.kind; + if (kind == eOTelSpanKind.SERVER || kind == eOTelSpanKind.CONSUMER) { + // Request + telemetryItem = _createRequestTelemetryItem(core, span); + } else if (kind == eOTelSpanKind.CLIENT || kind == eOTelSpanKind.PRODUCER || kind == eOTelSpanKind.INTERNAL) { + // RemoteDependency + telemetryItem = _createDependencyTelemetryItem(core, span); + } else { + //diag.error(`Unsupported span kind ${span.kind}`); + } + + if (telemetryItem) { + // Set start time for the telemetry item from the event, not the time it is being processed (the default) + // The channel envelope creator uses this value when creating the envelope only when defined, otherwise it + // uses the time when the item is being processed + let baseData = telemetryItem.baseData = telemetryItem.baseData || {}; + baseData.startTime = new Date(hrTimeToMilliseconds(span.startTime)); + + // Add dt extension to the telemetry item + let ext = telemetryItem.ext = telemetryItem.ext || {}; + let dt = ext["dt"] = ext["dt"] || {}; + + // Don't overwrite any existing values + dt.spanId = dt.spanId || span.spanContext().spanId; + dt.traceId = dt.traceId || span.spanContext().traceId; + + let traceFlags = span.spanContext().traceFlags; + if (!isNullOrUndefined(traceFlags)) { + dt.traceFlags = dt.traceFlags || traceFlags; + } + + // _populateTagsFromSpan(telemetryItem, span); + _populatePropertiesFromAttributes(telemetryItem, container); + + let sampleRate = container.get("microsoft.sample_rate"); + if (!isNullOrUndefined(sampleRate)) { + (telemetryItem as any).sampleRate = Number(sampleRate); + } + + // Azure SDK + let azNamespace = container.get(AzNamespace); + if (azNamespace) { + if (span.kind === eOTelSpanKind.INTERNAL) { + baseData.type = eDependencyTypes.InProc + " | " + azNamespace; + } + + if (azNamespace === MicrosoftEventHub) { + _parseEventHubSpan(telemetryItem, span); + } + } + } + + return telemetryItem; +} diff --git a/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts b/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts index ef59e6932..7c33e8e71 100644 --- a/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts +++ b/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts @@ -43,16 +43,16 @@ function _loadPackageJson(cb:(isNightly: boolean, packageJson: any) => IPromise< } function _checkSize(checkType: string, maxSize: number, size: number, isNightly: boolean): void { - if (isNightly) { + if (isNightly) { maxSize += .5; } QUnit.assert.ok(size <= maxSize, `exceed ${maxSize} KB, current ${checkType} size is: ${size} KB`); -} +} export class FileSizeCheckTest extends AITestClass { - private readonly MAX_BUNDLE_SIZE = 80; - private readonly MAX_DEFLATE_SIZE = 34; + private readonly MAX_BUNDLE_SIZE = 90; + private readonly MAX_DEFLATE_SIZE = 38; private readonly bundleFilePath = "../bundle/es5/ms.core.min.js"; public testInitialize() { diff --git a/shared/AppInsightsCommon/src/HelperFuncs.ts b/shared/AppInsightsCommon/src/HelperFuncs.ts index ddd212fea..ab153a723 100644 --- a/shared/AppInsightsCommon/src/HelperFuncs.ts +++ b/shared/AppInsightsCommon/src/HelperFuncs.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { IPlugin, arrForEach, isString } from "@microsoft/applicationinsights-core-js"; +import { IPlugin, arrForEach, isString, isTimeSpan } from "@microsoft/applicationinsights-core-js"; import { mathFloor, mathRound } from "@nevware21/ts-utils"; const strEmpty = ""; @@ -17,7 +17,12 @@ export function stringToBoolOrDefault(str: any, defaultValue = false): boolean { /** * Convert ms to c# time span format */ -export function msToTimeSpan(totalms: number): string { +export function msToTimeSpan(totalms: number | string): string { + if (isTimeSpan(totalms)) { + // Already in time span format + return totalms; + } + if (isNaN(totalms) || totalms < 0) { totalms = 0; } diff --git a/shared/AppInsightsCommon/src/Interfaces/Context/ISample.ts b/shared/AppInsightsCommon/src/Interfaces/Context/ISample.ts index cc46225ef..2b60dad23 100644 --- a/shared/AppInsightsCommon/src/Interfaces/Context/ISample.ts +++ b/shared/AppInsightsCommon/src/Interfaces/Context/ISample.ts @@ -8,6 +8,5 @@ export interface ISample { * Sample rate */ sampleRate: number; - isSampledIn(envelope: ITelemetryItem): boolean; } \ No newline at end of file diff --git a/shared/AppInsightsCommon/src/Interfaces/Contracts/ContextTagKeys.ts b/shared/AppInsightsCommon/src/Interfaces/Contracts/ContextTagKeys.ts index c02da207c..76c0d766b 100644 --- a/shared/AppInsightsCommon/src/Interfaces/Contracts/ContextTagKeys.ts +++ b/shared/AppInsightsCommon/src/Interfaces/Contracts/ContextTagKeys.ts @@ -227,6 +227,7 @@ export interface IContextTagKeys { readonly internalSdkSrc: string; } +/*#__NO_SIDE_EFFECTS__*/ export class ContextTagKeys extends createClassFromInterface({ applicationVersion: _aiApplication("ver"), applicationBuild: _aiApplication("build"), diff --git a/shared/AppInsightsCommon/src/Interfaces/Contracts/RequestData.ts b/shared/AppInsightsCommon/src/Interfaces/Contracts/RequestData.ts deleted file mode 100644 index 9b37549d4..000000000 --- a/shared/AppInsightsCommon/src/Interfaces/Contracts/RequestData.ts +++ /dev/null @@ -1,50 +0,0 @@ -// // Copyright (c) Microsoft Corporation. All rights reserved. -// // Licensed under the MIT License. - -// import { IDomain } from "./IDomain"; - -// /** -// * An instance of Request represents completion of an external request to the application to do work and contains a summary of that request execution and the results. -// */ -// export class RequestData implements IDomain { - -// /** -// * Schema version -// */ -// public ver: number = 2; - -// /** -// * Identifier of a request call instance. Used for correlation between request and other telemetry items. -// */ -// public id: string; - -// /** -// * Source of the request. Examples are the instrumentation key of the caller or the ip address of the caller. -// */ -// public source: string; - -// /** -// * Name of the request. Represents code path taken to process request. Low cardinality value to allow better grouping of requests. For HTTP requests it represents the HTTP method and URL path template like 'GET /values/{id}'. -// */ -// public name: string; - -// /** -// * Indication of successful or unsuccessful call. -// */ -// public success: boolean; - -// /** -// * Request URL with all query string parameters. -// */ -// public url: string; - -// /** -// * Collection of custom properties. -// */ -// public properties: any = {}; - -// /** -// * Collection of custom measurements. -// */ -// public measurements: any = {}; -// } diff --git a/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts b/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts index d9ee24a0f..34a382725 100644 --- a/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts +++ b/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts @@ -70,7 +70,7 @@ export interface ICorrelationConfig { * This is used to determine which headers are sent with requests and how the * telemetry is correlated across services. * @default AI_AND_W3C - * @see {@link DistributedTracingModes} + * @see {@link eDistributedTracingModes} */ distributedTracingMode: DistributedTracingModes; diff --git a/shared/AppInsightsCommon/src/Interfaces/IRequestTelemetry.ts b/shared/AppInsightsCommon/src/Interfaces/IRequestTelemetry.ts new file mode 100644 index 000000000..f277b1c7d --- /dev/null +++ b/shared/AppInsightsCommon/src/Interfaces/IRequestTelemetry.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IPartC } from "./IPartC"; + +/** + * This defines the contract for request telemetry items that are passed to Application Insights API (track) + */ +export interface IRequestTelemetry extends IPartC { + /** + * Identifier of a request call instance. Used for correlation between request and other telemetry items. + */ + id: string; + + /** + * Name of the request. Represents code path taken to process request. Low cardinality value to allow better grouping of requests. For HTTP requests it represents the HTTP method and URL path template like 'GET /values/\{id\}'. + */ + name?: string; + + /** + * Request duration in milliseconds. + */ + duration: number; + + /** + * Indication of successful or unsuccessful call. + */ + success: boolean; + + /** + * Result of a request execution. HTTP status code for HTTP requests. + */ + responseCode: number; + + /** + * Source of the request. Examples are the instrumentation key of the caller or the ip address of the caller. + */ + source?: string; + + /** + * Request URL with all query string parameters. + */ + url?: string; +} \ No newline at end of file diff --git a/shared/AppInsightsCommon/src/Interfaces/PartAExtensions.ts b/shared/AppInsightsCommon/src/Interfaces/PartAExtensions.ts index 1aa523099..361ea70fe 100644 --- a/shared/AppInsightsCommon/src/Interfaces/PartAExtensions.ts +++ b/shared/AppInsightsCommon/src/Interfaces/PartAExtensions.ts @@ -11,4 +11,4 @@ export const Extensions = { SDKExt: "sdk" }; -export let CtxTagKeys = new ContextTagKeys(); \ No newline at end of file +export let CtxTagKeys = (/* #__PURE__ */ new ContextTagKeys()); \ No newline at end of file diff --git a/shared/AppInsightsCommon/src/Interfaces/Telemetry/ISerializable.ts b/shared/AppInsightsCommon/src/Interfaces/Telemetry/ISerializable.ts index 71db06e0c..36f6b61ce 100644 --- a/shared/AppInsightsCommon/src/Interfaces/Telemetry/ISerializable.ts +++ b/shared/AppInsightsCommon/src/Interfaces/Telemetry/ISerializable.ts @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { FieldType } from "../../Enums"; + export interface ISerializable { /** * The set of fields for a serializable object. * This defines the serialization order and a value of true/false * for each field defines whether the field is required or not. */ - aiDataContract: any; + aiDataContract: { [key: string]: FieldType | (() => FieldType) }; } \ No newline at end of file diff --git a/shared/AppInsightsCommon/src/Telemetry/Common/Data.ts b/shared/AppInsightsCommon/src/Telemetry/Common/Data.ts index 753f87d62..ad7e91d7e 100644 --- a/shared/AppInsightsCommon/src/Telemetry/Common/Data.ts +++ b/shared/AppInsightsCommon/src/Telemetry/Common/Data.ts @@ -5,6 +5,10 @@ import { FieldType } from "../../Enums"; import { IData } from "../../Interfaces/Contracts/IData"; import { ISerializable } from "../../Interfaces/Telemetry/ISerializable"; +/** + * @deprecated - will be removed in future releases as this was only used by the applicationinsights-channel-js package. + * And it no longer uses this class. + */ export class Data implements IData, ISerializable { /** diff --git a/shared/AppInsightsCommon/src/Telemetry/DataTypes.ts b/shared/AppInsightsCommon/src/Telemetry/DataTypes.ts new file mode 100644 index 000000000..13e6ae95e --- /dev/null +++ b/shared/AppInsightsCommon/src/Telemetry/DataTypes.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export const EventDataType = "EventData"; +export const ExceptionDataType = "ExceptionData"; +export const MetricDataType = "MetricData"; +export const PageViewDataType = "PageviewData"; +export const PageViewPerformanceDataType = "PageviewPerformanceData"; +export const RemoteDependencyDataType = "RemoteDependencyData"; +export const RequestDataType = "RequestData"; +export const TraceDataType = "MessageData"; diff --git a/shared/AppInsightsCommon/src/Telemetry/EnvelopeTypes.ts b/shared/AppInsightsCommon/src/Telemetry/EnvelopeTypes.ts new file mode 100644 index 000000000..bd72cbf7a --- /dev/null +++ b/shared/AppInsightsCommon/src/Telemetry/EnvelopeTypes.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/* #__NO_SIDE_EFFECTS__# */ +function _AddPrefix(name: string) { + return "Microsoft.ApplicationInsights.{0}." + name; +} + +export const EventEnvelopeType = (/* __PURE__ */ _AddPrefix("Event")); +export const ExceptionEnvelopeType = (/* __PURE__ */ _AddPrefix("Exception")); +export const MetricEnvelopeType = (/* __PURE__ */ _AddPrefix("Metric")); +export const PageViewEnvelopeType = (/* __PURE__ */ _AddPrefix("Pageview")); +export const PageViewPerformanceEnvelopeType = (/* __PURE__ */ _AddPrefix("PageviewPerformance")); +export const RemoteDependencyEnvelopeType = (/* __PURE__ */ _AddPrefix("RemoteDependency")); +export const RequestEnvelopeType = (/* __PURE__ */ _AddPrefix("Request")); +export const TraceEnvelopeType = (/* __PURE__ */ _AddPrefix("Message")); \ No newline at end of file diff --git a/shared/AppInsightsCommon/src/Telemetry/Event.ts b/shared/AppInsightsCommon/src/Telemetry/Event.ts index 8dce98f97..9cb6af568 100644 --- a/shared/AppInsightsCommon/src/Telemetry/Event.ts +++ b/shared/AppInsightsCommon/src/Telemetry/Event.ts @@ -7,10 +7,19 @@ import { FieldType } from "../Enums"; import { IEventData } from "../Interfaces/Contracts/IEventData"; import { ISerializable } from "../Interfaces/Telemetry/ISerializable"; import { dataSanitizeMeasurements, dataSanitizeProperties, dataSanitizeString } from "./Common/DataSanitizer"; +import { EventDataType } from "./DataTypes"; +import { EventEnvelopeType } from "./EnvelopeTypes"; export class Event implements IEventData, ISerializable { - public static envelopeType = "Microsoft.ApplicationInsights.{0}.Event"; - public static dataType = "EventData"; + /** + * @deprecated Use the constant EventEnvelopeType instead. + */ + public static envelopeType = EventEnvelopeType; + + /** + * @deprecated Use the constant EventDataType instead. + */ + public static dataType = EventDataType; public aiDataContract = { ver: FieldType.Required, diff --git a/shared/AppInsightsCommon/src/Telemetry/Exception.ts b/shared/AppInsightsCommon/src/Telemetry/Exception.ts index c45da9bf9..6abc5f8f0 100644 --- a/shared/AppInsightsCommon/src/Telemetry/Exception.ts +++ b/shared/AppInsightsCommon/src/Telemetry/Exception.ts @@ -16,6 +16,8 @@ import { ISerializable } from "../Interfaces/Telemetry/ISerializable"; import { dataSanitizeException, dataSanitizeMeasurements, dataSanitizeMessage, dataSanitizeProperties, dataSanitizeString } from "./Common/DataSanitizer"; +import { ExceptionDataType } from "./DataTypes"; +import { ExceptionEnvelopeType } from "./EnvelopeTypes"; // These Regex covers the following patterns // 1. Chrome/Firefox/IE/Edge: @@ -535,9 +537,15 @@ export function _formatErrorCode(errorObj:any) { } export class Exception implements IExceptionData, ISerializable { + /** + * @deprecated Use the constant ExceptionEnvelopeType instead. + */ + public static envelopeType = ExceptionEnvelopeType; - public static envelopeType = "Microsoft.ApplicationInsights.{0}.Exception"; - public static dataType = "ExceptionData"; + /** + * @deprecated Use the constant ExceptionDataType instead. + */ + public static dataType = ExceptionDataType; public id?: string; public problemGroup?: string; diff --git a/shared/AppInsightsCommon/src/Telemetry/Metric.ts b/shared/AppInsightsCommon/src/Telemetry/Metric.ts index 45f08de9b..a2b955eab 100644 --- a/shared/AppInsightsCommon/src/Telemetry/Metric.ts +++ b/shared/AppInsightsCommon/src/Telemetry/Metric.ts @@ -8,11 +8,19 @@ import { IMetricData } from "../Interfaces/Contracts/IMetricData"; import { ISerializable } from "../Interfaces/Telemetry/ISerializable"; import { DataPoint } from "./Common/DataPoint"; import { dataSanitizeMeasurements, dataSanitizeProperties, dataSanitizeString } from "./Common/DataSanitizer"; +import { MetricDataType } from "./DataTypes"; +import { MetricEnvelopeType } from "./EnvelopeTypes"; export class Metric implements IMetricData, ISerializable { + /** + * @deprecated Use the constant MetricEnvelopeType instead. + */ + public static envelopeType = MetricEnvelopeType; - public static envelopeType = "Microsoft.ApplicationInsights.{0}.Metric"; - public static dataType = "MetricData"; + /** + * @deprecated Use the constant MetricDataType instead. + */ + public static dataType = MetricDataType; public aiDataContract = { ver: FieldType.Required, diff --git a/shared/AppInsightsCommon/src/Telemetry/PageView.ts b/shared/AppInsightsCommon/src/Telemetry/PageView.ts index de4df7f27..feaa39993 100644 --- a/shared/AppInsightsCommon/src/Telemetry/PageView.ts +++ b/shared/AppInsightsCommon/src/Telemetry/PageView.ts @@ -10,11 +10,19 @@ import { ISerializable } from "../Interfaces/Telemetry/ISerializable"; import { dataSanitizeId, dataSanitizeMeasurements, dataSanitizeProperties, dataSanitizeString, dataSanitizeUrl } from "./Common/DataSanitizer"; +import { PageViewDataType } from "./DataTypes"; +import { PageViewEnvelopeType } from "./EnvelopeTypes"; export class PageView implements IPageViewData, ISerializable { + /** + * @deprecated Use the constant PageViewEnvelopeType instead. + */ + public static envelopeType = PageViewEnvelopeType; - public static envelopeType = "Microsoft.ApplicationInsights.{0}.Pageview"; - public static dataType = "PageviewData"; + /** + * @deprecated Use the constant PageViewDataType instead. + */ + public static dataType = PageViewDataType; public aiDataContract = { ver: FieldType.Required, diff --git a/shared/AppInsightsCommon/src/Telemetry/PageViewPerformance.ts b/shared/AppInsightsCommon/src/Telemetry/PageViewPerformance.ts index 29f9776d4..8f13eb350 100644 --- a/shared/AppInsightsCommon/src/Telemetry/PageViewPerformance.ts +++ b/shared/AppInsightsCommon/src/Telemetry/PageViewPerformance.ts @@ -8,11 +8,19 @@ import { IPageViewPerfData } from "../Interfaces/Contracts/IPageViewPerfData"; import { IPageViewPerformanceTelemetry } from "../Interfaces/IPageViewPerformanceTelemetry"; import { ISerializable } from "../Interfaces/Telemetry/ISerializable"; import { dataSanitizeMeasurements, dataSanitizeProperties, dataSanitizeString, dataSanitizeUrl } from "./Common/DataSanitizer"; +import { PageViewPerformanceDataType } from "./DataTypes"; +import { PageViewPerformanceEnvelopeType } from "./EnvelopeTypes"; export class PageViewPerformance implements IPageViewPerfData, ISerializable { + /** + * @deprecated Use the constant PageViewPerformanceEnvelopeType instead. + */ + public static envelopeType = PageViewPerformanceEnvelopeType; - public static envelopeType = "Microsoft.ApplicationInsights.{0}.PageviewPerformance"; - public static dataType = "PageviewPerformanceData"; + /** + * @deprecated Use the constant PageViewPerformanceDataType instead. + */ + public static dataType = PageViewPerformanceDataType; public aiDataContract = { ver: FieldType.Required, diff --git a/shared/AppInsightsCommon/src/Telemetry/RemoteDependencyData.ts b/shared/AppInsightsCommon/src/Telemetry/RemoteDependencyData.ts index 4ba3d1c78..5b6492053 100644 --- a/shared/AppInsightsCommon/src/Telemetry/RemoteDependencyData.ts +++ b/shared/AppInsightsCommon/src/Telemetry/RemoteDependencyData.ts @@ -8,11 +8,23 @@ import { IRemoteDependencyData } from "../Interfaces/Contracts/IRemoteDependency import { ISerializable } from "../Interfaces/Telemetry/ISerializable"; import { AjaxHelperParseDependencyPath } from "../Util"; import { dataSanitizeMeasurements, dataSanitizeProperties, dataSanitizeString, dataSanitizeUrl } from "./Common/DataSanitizer"; +import { RemoteDependencyDataType } from "./DataTypes"; +import { RemoteDependencyEnvelopeType } from "./EnvelopeTypes"; +/** + * @deprecated - will be removed in future releases as this was only used by the applicationinsights-channel-js package. + * And it no longer uses this class. + */ export class RemoteDependencyData implements IRemoteDependencyData, ISerializable { + /** + * @deprecated Use the constant RemoteDependencyEnvelopeType instead. + */ + public static envelopeType = RemoteDependencyEnvelopeType; - public static envelopeType = "Microsoft.ApplicationInsights.{0}.RemoteDependency"; - public static dataType = "RemoteDependencyData"; + /** + * @deprecated Use the constant RemoteDependencyDataType instead. + */ + public static dataType = RemoteDependencyDataType; public aiDataContract = { id: FieldType.Required, @@ -104,7 +116,7 @@ export class RemoteDependencyData implements IRemoteDependencyData, ISerializabl _self.id = id; _self.duration = msToTimeSpan(value); _self.success = success; - _self.resultCode = resultCode + ""; + _self.resultCode = "" + resultCode; _self.type = dataSanitizeString(logger, requestAPI); diff --git a/shared/AppInsightsCommon/src/Telemetry/Trace.ts b/shared/AppInsightsCommon/src/Telemetry/Trace.ts index f74c78ef9..b4316ed58 100644 --- a/shared/AppInsightsCommon/src/Telemetry/Trace.ts +++ b/shared/AppInsightsCommon/src/Telemetry/Trace.ts @@ -8,11 +8,19 @@ import { IMessageData } from "../Interfaces/Contracts/IMessageData"; import { SeverityLevel } from "../Interfaces/Contracts/SeverityLevel"; import { ISerializable } from "../Interfaces/Telemetry/ISerializable"; import { dataSanitizeMeasurements, dataSanitizeMessage, dataSanitizeProperties } from "./Common/DataSanitizer"; +import { TraceDataType } from "./DataTypes"; +import { TraceEnvelopeType } from "./EnvelopeTypes"; export class Trace implements IMessageData, ISerializable { + /** + * @deprecated Use the constant TraceEnvelopeType instead. + */ + public static envelopeType = TraceEnvelopeType; - public static envelopeType = "Microsoft.ApplicationInsights.{0}.Message"; - public static dataType = "MessageData"; + /** + * @deprecated Use the constant TraceDataType instead. + */ + public static dataType = TraceDataType; public aiDataContract = { ver: FieldType.Required, diff --git a/shared/AppInsightsCommon/src/applicationinsights-common.ts b/shared/AppInsightsCommon/src/applicationinsights-common.ts index 23902f55e..d7d3e4ada 100644 --- a/shared/AppInsightsCommon/src/applicationinsights-common.ts +++ b/shared/AppInsightsCommon/src/applicationinsights-common.ts @@ -22,6 +22,7 @@ export { Metric } from "./Telemetry/Metric"; export { PageView } from "./Telemetry/PageView"; export { IPageViewData } from "./Interfaces/Contracts/IPageViewData"; export { RemoteDependencyData } from "./Telemetry/RemoteDependencyData"; +export { IRemoteDependencyData } from "./Interfaces/Contracts/IRemoteDependencyData"; export { IEventTelemetry } from "./Interfaces/IEventTelemetry"; export { ITraceTelemetry } from "./Interfaces/ITraceTelemetry"; export { IMetricTelemetry } from "./Interfaces/IMetricTelemetry"; @@ -29,6 +30,7 @@ export { IDependencyTelemetry } from "./Interfaces/IDependencyTelemetry"; export { IExceptionTelemetry, IAutoExceptionTelemetry, IExceptionInternal } from "./Interfaces/IExceptionTelemetry"; export { IPageViewTelemetry, IPageViewTelemetryInternal } from "./Interfaces/IPageViewTelemetry"; export { IPageViewPerformanceTelemetry, IPageViewPerformanceTelemetryInternal } from "./Interfaces/IPageViewPerformanceTelemetry"; +export { IRequestTelemetry } from "./Interfaces/IRequestTelemetry"; export { Trace } from "./Telemetry/Trace"; export { PageViewPerformance } from "./Telemetry/PageViewPerformance"; export { Data } from "./Telemetry/Common/Data"; @@ -36,6 +38,14 @@ export { eSeverityLevel, SeverityLevel } from "./Interfaces/Contracts/SeverityLe export { IConfig, ConfigurationManager } from "./Interfaces/IConfig"; export { IStorageBuffer } from "./Interfaces/IStorageBuffer"; export { IContextTagKeys, ContextTagKeys } from "./Interfaces/Contracts/ContextTagKeys"; +export { + EventDataType, ExceptionDataType, MetricDataType, PageViewDataType, PageViewPerformanceDataType, RemoteDependencyDataType, + RequestDataType, TraceDataType +} from "./Telemetry/DataTypes"; +export { + EventEnvelopeType, ExceptionEnvelopeType, MetricEnvelopeType, PageViewEnvelopeType, PageViewPerformanceEnvelopeType, + RemoteDependencyEnvelopeType, RequestEnvelopeType, TraceEnvelopeType +} from "./Telemetry/EnvelopeTypes" export { DataSanitizerValues, dataSanitizeKeyAndAddUniqueness, dataSanitizeKey, dataSanitizeString, dataSanitizeUrl, dataSanitizeMessage, diff --git a/shared/AppInsightsCore/SpanImplementationSummary.md b/shared/AppInsightsCore/SpanImplementationSummary.md new file mode 100644 index 000000000..3e5815b1d --- /dev/null +++ b/shared/AppInsightsCore/SpanImplementationSummary.md @@ -0,0 +1,100 @@ +# Span Implementation Summary + +## What Was Implemented + +Successfully implemented an OpenTelemetry-like span functionality in ApplicationInsights using a provider pattern architecture. + +## Key Components Created + +### Core Package (AppInsightsCore) + +1. **Interfaces**: + - `IOTelSpan`: OpenTelemetry-like span interface (simplified) + - `IOTelSpanContext`: Span context interface + - `ITraceProvider`: Provider interface for span creation + - `SpanOptions`: Configuration options for spans + +2. **Core Integration**: + - Extended `IAppInsightsCore` with provider management methods + - Updated `AppInsightsCore` implementation to use provider pattern + - Added utility functions for span context creation + +3. **Utilities**: + - `createOTelSpanContext()`: Creates span contexts + - `isSpanContext()`: Type guard for span contexts + - `wrapDistributedTrace()`: Wraps distributed trace contexts + +### Web Package (AISKU) + +1. **Concrete Implementation**: + - `AppInsightsSpan`: ApplicationInsights-specific span implementation + - `AppInsightsTraceProvider`: Provider that creates ApplicationInsights spans + - `createSpan()`: Factory function for creating spans + +2. **Integration**: + - Exported span implementation from web package + - Provider can be registered with core SDK + +## Architecture Benefits + +### Provider Pattern Advantages + +1. **Separation of Concerns**: Core manages lifecycle, providers handle creation +2. **Flexibility**: Different SKUs can provide their own implementations +3. **Extensibility**: Easy to add new span providers for different scenarios +4. **Type Safety**: Full TypeScript support with proper interfaces + +### OpenTelemetry Compatibility + +1. **Familiar API**: Similar to OpenTelemetry span interface +2. **Standard Patterns**: Uses established tracing concepts +3. **Future-Proof**: Easy to extend with more OpenTelemetry features + +## Usage Flow + +1. **Setup**: Register a trace provider with the core SDK +2. **Creation**: Use `appInsightsCore.startSpan()` to create spans +3. **Management**: Core delegates to provider for span creation +4. **Lifecycle**: Spans follow standard OpenTelemetry patterns + +## Removed Features + +As requested, the following methods were removed from the span interface: +- `addEvent()`: Event recording functionality +- `setStatus()`: Status setting functionality + +This keeps the implementation focused on ApplicationInsights core needs. + +## Files Modified/Created + +### Core Package +- `shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/ITraceProvider.ts` (new) +- `shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts` (modified) +- `shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts` (modified) +- `shared/AppInsightsCore/src/applicationinsights-core-js.ts` (modified - exports) + +### Web Package +- `AISKU/src/OpenTelemetry/trace/span.ts` (new) +- `AISKU/src/OpenTelemetry/trace/AppInsightsTraceProvider.ts` (new) +- `AISKU/src/applicationinsights-web.ts` (modified - exports) + +### Documentation +- `AISKU/SpanImplementation.md` (new) + +## Testing + +Both packages build successfully: +- ✅ Core package compiles and exports correctly +- ✅ Web package compiles with span implementation +- ✅ Provider pattern works as designed +- ✅ TypeScript types are properly exported + +## Next Steps + +1. **Testing**: Add unit tests for span implementation +2. **Integration**: Integrate with ApplicationInsights telemetry pipeline +3. **Documentation**: Add API documentation +4. **Examples**: Create more usage examples +5. **Other SKUs**: Implement providers for other ApplicationInsights packages + +The implementation is complete and follows the requested architecture of having spans in the web package managed by the core through a provider pattern similar to OpenTelemetry's TracerProvider. diff --git a/shared/AppInsightsCore/Span_Implementation_Refactoring_Summary.md b/shared/AppInsightsCore/Span_Implementation_Refactoring_Summary.md new file mode 100644 index 000000000..06a123359 --- /dev/null +++ b/shared/AppInsightsCore/Span_Implementation_Refactoring_Summary.md @@ -0,0 +1,126 @@ +# Span Implementation Refactoring Summary + +## Changes Implemented + +### 1. ✅ Removed AppInsightsTraceProvider Class +- **File**: `AISKU/src/OpenTelemetry/trace/AppInsightsTraceProvider.ts` +- **Change**: Replaced the `AppInsightsTraceProvider` class with a factory function `createTraceProvider()` +- **Reason**: The main AISKU should internally implement this during initialization rather than exporting a class + +### 2. ✅ Removed AppInsightsSpan Class +- **File**: `AISKU/src/OpenTelemetry/trace/span.ts` +- **Change**: + - Removed the `AppInsightsSpan` class + - Implemented span functionality inline in the `createSpan` factory function using closure-based approach + - Added `SpanEndCallback` type for handling span end events +- **Reason**: Eliminates class overhead and makes the implementation more internal + +### 3. ✅ Made createSpan Internal-Only +- **File**: `AISKU/src/OpenTelemetry/trace/span.ts` +- **Change**: + - Made `createSpan` function internal (not exported from package) + - Added `onSpanEnd` callback parameter to support trace provider integration + - Span `end()` function now calls back into the provided callback function +- **Reason**: Factory function should be internal and support telemetry event creation + +### 4. ✅ Updated Package Exports +- **File**: `AISKU/src/applicationinsights-web.ts` +- **Change**: Removed exports for `AppInsightsSpan`, `createSpan`, and `AppInsightsTraceProvider` +- **Reason**: These should be internal-only implementations + +### 5. ✅ Integrated Trace Provider in AISKU Initialization +- **File**: `AISKU/src/AISku.ts` +- **Change**: + - Added trace provider setup in the `loadAppInsights` initialization function + - Created span end callback that converts span data to telemetry using `trackTrace` + - Set the trace provider on the core using `_core.setTraceProvider()` +- **Reason**: Main AISKU should internally implement and configure the trace provider + +## Technical Implementation Details + +### Inline Span Implementation +The span is now implemented using closures instead of a class: + +```typescript +function createSpan(name, parent, options, onSpanEnd) { + // Private variables in closure + let _spanContext = spanContext; + let _attributes = {}; + let _name = name; + let _ended = false; + + // Return span implementation object + const span = { + spanContext: () => _spanContext, + setAttribute: (key, value) => { /* implementation */ }, + setAttributes: (attributes) => { /* implementation */ }, + updateName: (newName) => { /* implementation */ }, + end: (endTime) => { + if (!_ended) { + _ended = true; + _endTime = endTime || utcNow(); + + // Call the end callback if provided + if (onSpanEnd) { + onSpanEnd(span, _endTime); + } + } + }, + isRecording: () => !_ended + }; + + return span; +} +``` + +### Span End Callback Integration +When a span ends, it calls back to the AISKU which creates telemetry: + +```typescript +const traceProvider = createTraceProvider((span, endTime) => { + const spanData = span as any; // Access helper methods + if (spanData.getName && spanData.getAttributes) { + const name = spanData.getName(); + const attributes = spanData.getAttributes(); + + // Create trace telemetry for the span + _self.trackTrace({ + message: `Span: ${name}`, + severityLevel: 1, + properties: { + ...attributes, + spanId: span.spanContext().spanId, + traceId: span.spanContext().traceId, + startTime: spanData.getStartTime().toString(), + endTime: endTime.toString(), + duration: (endTime - spanData.getStartTime()).toString() + } + }); + } +}); +``` + +## Benefits + +1. **Simplified Architecture**: No longer exposes internal span classes +2. **Better Encapsulation**: Span implementation is truly internal to the SDK +3. **Automatic Telemetry**: Spans automatically generate telemetry events when they end +4. **Memory Efficient**: Closure-based implementation reduces object overhead +5. **Cleaner API**: Only the necessary interfaces are exposed publicly + +## Files Modified + +- ✅ `AISKU/src/OpenTelemetry/trace/AppInsightsTraceProvider.ts` - Converted to factory function +- ✅ `AISKU/src/OpenTelemetry/trace/span.ts` - Inline implementation with callback support +- ✅ `AISKU/src/applicationinsights-web.ts` - Removed exports +- ✅ `AISKU/src/AISku.ts` - Added trace provider integration +- ✅ `shared/AppInsightsCore/src/*` - Previous IOTelSpan interface reversion (completed) + +## Status + +✅ **COMPLETED** - All requested changes have been implemented: +- AppInsightsTraceProvider class removed and replaced with factory function +- AppInsightsSpan class removed and implemented inline in createSpan factory +- createSpan factory function is internal-only and includes callback for telemetry +- AISKU internally sets up trace provider during initialization +- Span end events automatically create trace telemetry through callback mechanism diff --git a/shared/AppInsightsCore/StartSpan_Implementation.md b/shared/AppInsightsCore/StartSpan_Implementation.md new file mode 100644 index 000000000..e69de29bb diff --git a/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts index 0cbe3060a..30ca34051 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts @@ -43,18 +43,18 @@ function _loadPackageJson(cb:(isNightly: boolean, packageJson: any) => IPromise< } function _checkSize(checkType: string, maxSize: number, size: number, isNightly: boolean): void { - if (isNightly) { + if (isNightly) { maxSize += .5; } Assert.ok(size <= maxSize, `exceed ${maxSize} KB, current ${checkType} size is: ${size} KB`); -} +} export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 74; - private readonly MAX_BUNDLE_SIZE = 74; - private readonly MAX_RAW_DEFLATE_SIZE = 32; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 32; + private readonly MAX_RAW_SIZE = 96; + private readonly MAX_BUNDLE_SIZE = 96; + private readonly MAX_RAW_DEFLATE_SIZE = 39; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 39; private readonly rawFilePath = "../dist/es5/applicationinsights-core-js.min.js"; private readonly prodFilePath = "../browser/es5/applicationinsights-core-js.min.js"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/attributeContainer.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/attributeContainer.Tests.ts new file mode 100644 index 000000000..c67ec9047 --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/attributeContainer.Tests.ts @@ -0,0 +1,3412 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { objKeys } from "@nevware21/ts-utils"; +import { createAttributeContainer, addAttributes, isAttributeContainer, createAttributeSnapshot } from "../../../../src/OpenTelemetry/attribute/attributeContainer"; +import { eAttributeFilter, IAttributeChangeInfo } from "../../../../src/OpenTelemetry/attribute/IAttributeContainer"; +import { eAttributeChangeOp } from "../../../../src/OpenTelemetry/enums/eAttributeChangeOp"; +import { IOTelConfig } from "../../../../src/OpenTelemetry/interfaces/config/IOTelConfig"; +import { IOTelAttributes } from "../../../../src/OpenTelemetry/interfaces/IOTelAttributes"; + +export class AttributeContainerTests extends AITestClass { + + public testInitialize() { + super.testInitialize(); + } + + public testCleanup() { + super.testCleanup(); + } + + public registerTests() { + this.testCase({ + name: "AttributeContainer: Basic functionality", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + + // Test initial state + Assert.equal(0, container.size, "Initial size should be 0"); + Assert.equal(0, container.droppedAttributes, "Initial dropped attributes should be 0"); + + // Test set/get + Assert.ok(container.set("key1", "value1"), "Should successfully set attribute"); + Assert.equal("value1", container.get("key1"), "Should retrieve correct value"); + Assert.equal(1, container.size, "Size should be 1 after adding one attribute"); + + // Test has + Assert.ok(container.has("key1"), "Should return true for existing key"); + Assert.ok(!container.has("nonexistent"), "Should return false for non-existent key"); + + // Test clear + container.clear(); + Assert.equal(0, container.size, "Size should be 0 after clear"); + Assert.ok(!container.has("key1"), "Should not have key after clear"); + Assert.equal(undefined, container.get("key1"), "Should return undefined after clear"); + } + }); + + this.testCase({ + name: "AttributeContainer: Hierarchical keys (dotted notation)", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + + // Test hierarchical keys + Assert.ok(container.set("parent.child", "value1"), "Should set hierarchical key"); + Assert.ok(container.set("parent.child2", "value2"), "Should set second child"); + Assert.equal("value1", container.get("parent.child"), "Should get hierarchical value"); + Assert.equal("value2", container.get("parent.child2"), "Should get second hierarchical value"); + Assert.equal(2, container.size, "Should count hierarchical keys correctly"); + } + }); + + this.testCase({ + name: "AttributeContainer: Iterator methods", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + + container.set("key1", "value1"); + container.set("key2", "value2"); + container.set("parent.child", "value3"); + + // Test keys iterator + const keys: string[] = []; + const keysIter = container.keys(); + let keysResult = keysIter.next(); + while (!keysResult.done) { + keys.push(keysResult.value); + keysResult = keysIter.next(); + } + Assert.equal(3, keys.length, "Should have 3 keys"); + Assert.ok(keys.includes("key1"), "Should include key1"); + Assert.ok(keys.includes("key2"), "Should include key2"); + Assert.ok(keys.includes("parent.child"), "Should include hierarchical key"); + + // Test entries iterator + const entries: [string, any, eAttributeFilter][] = []; + const entriesIter = container.entries(); + let entriesResult = entriesIter.next(); + while (!entriesResult.done) { + entries.push(entriesResult.value); + entriesResult = entriesIter.next(); + } + Assert.equal(3, entries.length, "Should have 3 entries"); + + // Test values iterator + const values: any[] = []; + const valuesIter = container.values(); + let valuesResult = valuesIter.next(); + while (!valuesResult.done) { + values.push(valuesResult.value); + valuesResult = valuesIter.next(); + } + Assert.equal(3, values.length, "Should have 3 values"); + Assert.ok(values.includes("value1"), "Should include value1"); + Assert.ok(values.includes("value2"), "Should include value2"); + Assert.ok(values.includes("value3"), "Should include value3"); + + // Test forEach + const forEachResults: { [key: string]: any } = {}; + container.forEach((key, value) => { + forEachResults[key] = value; + }); + Assert.equal(3, objKeys(forEachResults).length, "forEach should iterate over all items"); + Assert.equal("value1", forEachResults["key1"], "forEach should provide correct key-value pairs"); + } + }); + + this.testCase({ + name: "AttributeContainer: attributes accessor property", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + + container.set("key1", "value1"); + container.set("key2", 42); + container.set("parent.child", true); + + const attributes = container.attributes; + Assert.equal("value1", attributes["key1"], "Should include simple string attribute"); + Assert.equal(42, attributes["key2"], "Should include number attribute"); + Assert.equal(true, attributes["parent.child"], "Should include hierarchical boolean attribute"); + Assert.equal(3, objKeys(attributes).length, "Should have correct number of attributes"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container ID validation with name parameter", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test container with no name + const containerNoName = createAttributeContainer(otelCfg); + Assert.ok(containerNoName.id, "Container should have an ID even without name"); + Assert.ok(containerNoName.id.includes("."), "Container ID should include dot separator"); + + // Test container with custom name + const containerWithName = createAttributeContainer(otelCfg, "custom-container"); + Assert.ok(containerWithName.id, "Container with name should have an ID"); + Assert.ok(containerWithName.id.startsWith("custom-container."), "Container ID should start with provided name"); + + // Test container with descriptive name + const containerDescriptive = createAttributeContainer(otelCfg, "span-attributes"); + Assert.ok(containerDescriptive.id.startsWith("span-attributes."), "Container ID should include descriptive name"); + + // Test that different containers have different IDs + const container1 = createAttributeContainer(otelCfg, "test-1"); + const container2 = createAttributeContainer(otelCfg, "test-2"); + const container3 = createAttributeContainer(otelCfg, "test-1"); // Same name, different instance + + Assert.notEqual(container1.id, container2.id, "Different containers should have different IDs"); + Assert.notEqual(container1.id, container3.id, "Containers with same name should have different IDs"); + Assert.notEqual(container2.id, container3.id, "All containers should have unique IDs"); + + // Test ID format consistency + Assert.ok(container1.id.includes("test-1."), "Container 1 should include its name"); + Assert.ok(container2.id.includes("test-2."), "Container 2 should include its name"); + Assert.ok(container3.id.includes("test-1."), "Container 3 should include its name"); + } + }); + + this.testCase({ + name: "AttributeContainer: Snapshot ID validation includes source container details", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create source container with name + const sourceContainer = createAttributeContainer(otelCfg, "source-container"); + sourceContainer.set("key1", "value1"); + sourceContainer.set("key2", "value2"); + + // Create snapshot from container + const snapshot = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + Assert.ok(snapshot.id, "Snapshot should have an ID"); + Assert.ok(snapshot.id.includes("<-@["), "Snapshot ID should indicate it's a snapshot"); + Assert.ok(snapshot.id.includes(sourceContainer.id), "Snapshot ID should include source container ID"); + Assert.ok(snapshot.id.includes("]"), "Snapshot ID should close the snapshot notation"); + + // Test snapshot from container without explicit name + const unnamedContainer = createAttributeContainer(otelCfg); + unnamedContainer.set("test", "value"); + const unnamedSnapshot = createAttributeSnapshot(otelCfg, "unnamed-snapshot", unnamedContainer); + + Assert.ok(unnamedSnapshot.id.includes("<-@["), "Unnamed container snapshot should indicate it's a snapshot"); + Assert.ok(unnamedSnapshot.id.includes(unnamedContainer.id), "Unnamed snapshot should include source container ID"); + + // Test snapshot from plain object (should not include source container ID) + const plainAttributes = { "plain": "value", "another": 42 }; + const plainSnapshot = createAttributeSnapshot(otelCfg, "plain-snapshot", plainAttributes); + + // Plain object snapshots get a different naming pattern since there's no source container + Assert.ok(plainSnapshot.id, "Plain object snapshot should have an ID"); + Assert.notEqual(plainSnapshot.id, snapshot.id, "Plain snapshot should have different ID than container snapshot"); + } + }); + + this.testCase({ + name: "AttributeContainer: Snapshot ID uniqueness and inheritance details", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create multiple containers with the same name + const container1 = createAttributeContainer(otelCfg, "parent-container"); + const container2 = createAttributeContainer(otelCfg, "parent-container"); + + container1.set("key1", "value1"); + container2.set("key2", "value2"); + + // Create snapshots from both containers + const snapshot1 = createAttributeSnapshot(otelCfg, "snapshot1", container1); + const snapshot2 = createAttributeSnapshot(otelCfg, "snapshot2", container2); + + Assert.notEqual(snapshot1.id, snapshot2.id, "Snapshots from different containers should have different IDs"); + + // Both should reference their respective source container IDs + Assert.ok(snapshot1.id.includes(container1.id), "Snapshot 1 should reference container 1 ID"); + Assert.ok(snapshot2.id.includes(container2.id), "Snapshot 2 should reference container 2 ID"); + + // Test nested snapshot scenario + const childContainer = createAttributeContainer(otelCfg, "child-container", container1); + const childSnapshot = createAttributeSnapshot(otelCfg, "child-snapshot", childContainer); + + Assert.ok(childSnapshot.id.includes("<-@["), "Child snapshot should be identified as snapshot"); + Assert.ok(childSnapshot.id.includes(childContainer.id), "Child snapshot should reference child container ID"); + Assert.notEqual(childSnapshot.id, snapshot1.id, "Child snapshot should have different ID than parent snapshot"); + + // Verify the child container ID includes its name + Assert.ok(childContainer.id.includes("child-container."), "Child container ID should include its name"); + } + }); + + this.testCase({ + name: "AttributeContainer: Inheritance - basic functionality", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2", + "shared.key": "parent_shared" + }; + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + // Test inherited attributes are accessible + Assert.equal("parent_value1", container.get("parent.key1"), "Should get inherited attribute"); + Assert.equal("parent_value2", container.get("parent.key2"), "Should get second inherited attribute"); + Assert.equal("parent_shared", container.get("shared.key"), "Should get shared inherited attribute"); + Assert.ok(container.has("parent.key1"), "Should report inherited attribute as existing"); + Assert.equal(3, container.size, "Should count inherited attributes in size"); + } + }); + + this.testCase({ + name: "AttributeContainer: Inheritance - override behavior", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "shared.key": "parent_shared", + "override.me": "parent_override" + }; + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + // Test initial inherited state + Assert.equal(3, container.size, "Should start with 3 inherited attributes"); + Assert.equal("parent_shared", container.get("shared.key"), "Should get inherited value initially"); + + // Test overriding inherited attribute + Assert.ok(container.set("shared.key", "child_shared"), "Should successfully override inherited attribute"); + Assert.equal("child_shared", container.get("shared.key"), "Should get overridden value"); + Assert.equal(3, container.size, "Size should remain 3 after override"); + + // Test adding new attribute alongside inherited ones + Assert.ok(container.set("child.key", "child_value"), "Should add new attribute"); + Assert.equal("child_value", container.get("child.key"), "Should get new attribute value"); + Assert.equal("parent_value1", container.get("parent.key1"), "Should still get inherited attribute"); + Assert.equal(4, container.size, "Size should be 4 after adding new attribute"); + } + }); + + this.testCase({ + name: "AttributeContainer: Inheritance - iterator methods include inherited attributes", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2", + "shared.key": "parent_shared" + }; + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + // Add some child attributes + container.set("child.key", "child_value"); + container.set("shared.key", "child_shared"); // Override inherited + + // Test keys iterator includes both inherited and child keys + const keys: string[] = []; + const keysIter = container.keys(); + let keysResult = keysIter.next(); + while (!keysResult.done) { + keys.push(keysResult.value); + keysResult = keysIter.next(); + } + Assert.equal(4, keys.length, "Should have 4 total keys (2 child + 2 non-overridden inherited)"); + Assert.ok(keys.includes("child.key"), "Should include child key"); + Assert.ok(keys.includes("shared.key"), "Should include overridden key"); + Assert.ok(keys.includes("parent.key1"), "Should include inherited key1"); + Assert.ok(keys.includes("parent.key2"), "Should include inherited key2"); + + // Test entries iterator + const entries: [string, any, eAttributeFilter][] = []; + const entriesIter = container.entries(); + let entriesResult = entriesIter.next(); + while (!entriesResult.done) { + entries.push(entriesResult.value); + entriesResult = entriesIter.next(); + } + Assert.equal(4, entries.length, "Should have 4 total entries"); + + // Convert to map by extracting key-value pairs (ignoring source) + const entryMap = new Map(entries.map(entry => [entry[0], entry[1]])); + Assert.equal("child_value", entryMap.get("child.key"), "Should have child entry"); + Assert.equal("child_shared", entryMap.get("shared.key"), "Should have overridden value in entries"); + Assert.equal("parent_value1", entryMap.get("parent.key1"), "Should have inherited entry"); + + // Test forEach + const forEachResults: { [key: string]: any } = {}; + container.forEach((key, value) => { + forEachResults[key] = value; + }); + Assert.equal(4, objKeys(forEachResults).length, "forEach should iterate over all items including inherited"); + Assert.equal("child_shared", forEachResults["shared.key"], "forEach should use overridden value"); + Assert.equal("parent_value1", forEachResults["parent.key1"], "forEach should include inherited value"); + } + }); + + this.testCase({ + name: "AttributeContainer: Inheritance - attributes includes inherited", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": 42, + "shared.key": "parent_shared" + }; + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + container.set("child.key", "child_value"); + container.set("shared.key", "child_shared"); // Override + + const attributes = container.attributes; + Assert.equal("parent_value1", attributes["parent.key1"], "Should include inherited string"); + Assert.equal(42, attributes["parent.key2"], "Should include inherited number"); + Assert.equal("child_value", attributes["child.key"], "Should include child attribute"); + Assert.equal("child_shared", attributes["shared.key"], "Should use overridden value"); + Assert.equal(4, objKeys(attributes).length, "Should have correct total count"); + } + }); + + this.testCase({ + name: "AttributeContainer: Inheritance - clear removes inherited attributes", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2" + }; + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + // Verify inherited attributes are present + Assert.equal(2, container.size, "Should start with 2 inherited attributes"); + Assert.equal("parent_value1", container.get("parent.key1"), "Should access inherited attribute"); + + // Add child attribute + container.set("child.key", "child_value"); + Assert.equal(3, container.size, "Should have 3 total attributes"); + + // Clear should remove everything including inherited + container.clear(); + Assert.equal(0, container.size, "Should have 0 attributes after clear"); + Assert.ok(!container.has("parent.key1"), "Should not have inherited attribute after clear"); + Assert.ok(!container.has("child.key"), "Should not have child attribute after clear"); + Assert.equal(undefined, container.get("parent.key1"), "Should return undefined for inherited attribute after clear"); + } + }); + + this.testCase({ + name: "AttributeContainer: Inheritance - empty parent attributes", + test: () => { + const otelCfg: IOTelConfig = {}; + const emptyParent: IOTelAttributes = {}; + const container = createAttributeContainer(otelCfg, "test-empty-parent", emptyParent); + + // Should behave like container without inheritance + Assert.equal(0, container.size, "Should start with 0 attributes"); + container.set("key1", "value1"); + Assert.equal(1, container.size, "Should have 1 attribute after adding"); + Assert.equal("value1", container.get("key1"), "Should get attribute value"); + } + }); + + this.testCase({ + name: "AttributeContainer: Inheritance - null/undefined parent attributes", + test: () => { + const otelCfg: IOTelConfig = {}; + const containerNull = createAttributeContainer(otelCfg, "test-null", null as any); + const containerUndefined = createAttributeContainer(otelCfg, "test-undefined", undefined as any); + + // Both should behave like containers without inheritance + Assert.equal(0, containerNull.size, "Null parent should start with 0 attributes"); + Assert.equal(0, containerUndefined.size, "Undefined parent should start with 0 attributes"); + + containerNull.set("key1", "value1"); + containerUndefined.set("key2", "value2"); + + Assert.equal(1, containerNull.size, "Null parent container should have 1 after adding"); + Assert.equal(1, containerUndefined.size, "Undefined parent container should have 1 after adding"); + } + }); + + this.testCase({ + name: "AttributeContainer: addAttributes function with inheritance", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key": "parent_value" + }; + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + const attributesToAdd: IOTelAttributes = { + "added.key1": "added_value1", + "added.key2": 42, + "parent.key": "overridden_value" // Override inherited + }; + + addAttributes(container, attributesToAdd); + + Assert.equal(3, container.size, "Should have 3 attributes after adding"); + Assert.equal("added_value1", container.get("added.key1"), "Should have added attribute 1"); + Assert.equal(42, container.get("added.key2"), "Should have added attribute 2"); + Assert.equal("overridden_value", container.get("parent.key"), "Should override inherited attribute"); + } + }); + + this.testCase({ + name: "AttributeContainer: Complex inheritance scenarios", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "service.name": "parent-service", + "service.version": "1.0.0", + "deployment.environment": "staging", + "telemetry.sdk.name": "opentelemetry", + "common.attribute": "from_parent" + }; + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + // Add child-specific attributes + container.set("span.kind", "client"); + container.set("http.method", "GET"); + container.set("http.url", "https://api.example.com"); + + // Override some parent attributes + container.set("service.version", "2.0.0"); // Override + container.set("deployment.environment", "production"); // Override + + // Verify final state + Assert.equal(8, container.size, "Should have 8 total attributes (5 parent + 5 child - 2 overrides)"); + + // Check overridden values + Assert.equal("2.0.0", container.get("service.version"), "Should use child version"); + Assert.equal("production", container.get("deployment.environment"), "Should use child environment"); + + // Check inherited values + Assert.equal("parent-service", container.get("service.name"), "Should inherit service name"); + Assert.equal("opentelemetry", container.get("telemetry.sdk.name"), "Should inherit SDK name"); + Assert.equal("from_parent", container.get("common.attribute"), "Should inherit common attribute"); + + // Check child-only values + Assert.equal("client", container.get("span.kind"), "Should have child span kind"); + Assert.equal("GET", container.get("http.method"), "Should have child HTTP method"); + + const finalAttributes = container.attributes; + Assert.equal(8, objKeys(finalAttributes).length, "attributes should return correct count"); + Assert.equal("2.0.0", finalAttributes["service.version"], "attributes should use overridden value"); + Assert.equal("parent-service", finalAttributes["service.name"], "attributes should include inherited value"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Valid container identification", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + + // Test with valid container + Assert.ok(isAttributeContainer(container), "Should identify valid container"); + + // Test with inherited attributes + const inheritedAttribs: IOTelAttributes = { "parent.key": "value" }; + const containerWithInheritance = createAttributeContainer(otelCfg, "test-inherited", inheritedAttribs); + Assert.ok(isAttributeContainer(containerWithInheritance), "Should identify container with inheritance"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Child containers identification", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentContainer = createAttributeContainer(otelCfg, "parent-container"); + parentContainer.set("parent.key", "parent-value"); + + // Test child container created with child() method + const childContainer = parentContainer.child("child-container"); + Assert.ok(isAttributeContainer(childContainer), "Should identify child container as valid"); + + // Test snapshot child container created with child() method + const snapshotContainer = parentContainer.child("snapshot-container", true); + Assert.ok(isAttributeContainer(snapshotContainer), "Should identify snapshot child container as valid"); + + // Verify that child containers have the required methods and properties + Assert.ok(typeof childContainer.child === "function", "Child container should have child method"); + Assert.ok(typeof childContainer.listen === "function", "Child container should have listen method"); + Assert.ok("id" in childContainer, "Child container should have id property"); + Assert.ok("size" in childContainer, "Child container should have size property"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Invalid object identification - null and undefined", + test: () => { + // Test null and undefined + Assert.ok(!isAttributeContainer(null), "Should return false for null"); + Assert.ok(!isAttributeContainer(undefined), "Should return false for undefined"); + Assert.ok(!isAttributeContainer(void 0), "Should return false for void 0"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Invalid object identification - primitive types", + test: () => { + // Test primitive types + Assert.ok(!isAttributeContainer("string"), "Should return false for string"); + Assert.ok(!isAttributeContainer(123), "Should return false for number"); + Assert.ok(!isAttributeContainer(true), "Should return false for boolean"); + Assert.ok(!isAttributeContainer(false), "Should return false for boolean false"); + Assert.ok(!isAttributeContainer(Symbol("test")), "Should return false for symbol"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Invalid object identification - arrays and other objects", + test: () => { + // Test arrays and other objects + Assert.ok(!isAttributeContainer([]), "Should return false for empty array"); + Assert.ok(!isAttributeContainer([1, 2, 3]), "Should return false for array with values"); + Assert.ok(!isAttributeContainer({}), "Should return false for empty object"); + Assert.ok(!isAttributeContainer({ key: "value" }), "Should return false for plain object"); + Assert.ok(!isAttributeContainer(new Date()), "Should return false for Date object"); + Assert.ok(!isAttributeContainer(/regex/), "Should return false for RegExp object"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Missing required properties", + test: () => { + // Test objects missing size property + const missingSize = { + droppedAttributes: 0, + attributes: {}, + id: "test-id", + clear: () => {}, + get: () => {}, + has: () => {}, + set: () => {}, + del: () => {}, + keys: () => {}, + entries: () => {}, + forEach: () => {}, + values: () => {}, + child: () => {}, + listen: () => {} + }; + Assert.ok(!isAttributeContainer(missingSize), "Should return false when missing size property"); + + // Test objects missing droppedAttributes property + const missingDroppedAttributes = { + size: 0, + attributes: {}, + id: "test-id", + clear: () => {}, + get: () => {}, + has: () => {}, + set: () => {}, + del: () => {}, + keys: () => {}, + entries: () => {}, + forEach: () => {}, + values: () => {}, + child: () => {}, + listen: () => {} + }; + Assert.ok(!isAttributeContainer(missingDroppedAttributes), "Should return false when missing droppedAttributes property"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Missing required methods", + test: () => { + const baseObj = { + size: 0, + droppedAttributes: 0, + attributes: {} + }; + + // Test missing each required method + const requiredMethods = ["clear", "get", "has", "set", "del", "keys", "entries", "forEach", "values", "child", "listen"]; + + requiredMethods.forEach(methodName => { + const objMissingMethod = { ...baseObj }; + // Add all methods except the one we're testing + requiredMethods.forEach(method => { + if (method !== methodName) { + (objMissingMethod as any)[method] = () => {}; + } + }); + + Assert.ok(!isAttributeContainer(objMissingMethod), `Should return false when missing ${methodName} method`); + }); + } + }); + + this.testCase({ + name: "isAttributeContainer: Wrong property types", + test: () => { + const baseObj = { + clear: () => {}, + get: () => {}, + has: () => {}, + set: () => {}, + del: () => {}, + keys: () => {}, + entries: () => {}, + forEach: () => {}, + values: () => {}, + child: () => {}, + listen: () => {} + }; + + // Test size property with wrong type + const wrongSizeType = { + ...baseObj, + size: "not-a-number", + droppedAttributes: 0, + attributes: {} + }; + Assert.ok(!isAttributeContainer(wrongSizeType), "Should return false when size is not a number"); + + // Test droppedAttributes property with wrong type + const wrongDroppedAttributesType = { + ...baseObj, + size: 0, + droppedAttributes: "not-a-number" + }; + Assert.ok(!isAttributeContainer(wrongDroppedAttributesType), "Should return false when droppedAttributes is not a number"); + + // Test both properties with wrong types + const bothWrongTypes = { + ...baseObj, + size: true, + droppedAttributes: [] + }; + Assert.ok(!isAttributeContainer(bothWrongTypes), "Should return false when both properties have wrong types"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Wrong method types", + test: () => { + const baseObj = { + size: 0, + droppedAttributes: 0, + attributes: {} + }; + + const requiredMethods = ["clear", "get", "has", "set", "del", "keys", "entries", "forEach", "values", "child", "listen"]; + + requiredMethods.forEach(methodName => { + const objWithWrongMethodType = { ...baseObj }; + // Add all methods as functions except the one we're testing + requiredMethods.forEach(method => { + if (method !== methodName) { + (objWithWrongMethodType as any)[method] = () => {}; + } else { + (objWithWrongMethodType as any)[method] = "not-a-function"; + } + }); + + Assert.ok(!isAttributeContainer(objWithWrongMethodType), `Should return false when ${methodName} is not a function`); + }); + } + }); + + this.testCase({ + name: "isAttributeContainer: Objects with getter properties", + test: () => { + // Test object with lazy properties (similar to how attributeContainer implements size) + const objWithGetters = {}; + + // Define getters for size and droppedAttributes + Object.defineProperty(objWithGetters, "size", { + get: () => 5, + enumerable: true + }); + + Object.defineProperty(objWithGetters, "droppedAttributes", { + get: () => 2, + enumerable: true + }); + + Object.defineProperties(objWithGetters, { + attributes: { + get: () => ({}), + enumerable: true + } + }); + + // Add required methods + const requiredMethods = ["clear", "get", "has", "set", "del", "keys", "entries", "forEach", "values", "child", "listen"]; + requiredMethods.forEach(method => { + (objWithGetters as any)[method] = () => {}; + }); + + // Add required id property + (objWithGetters as any).id = "test-id"; + + Assert.ok(isAttributeContainer(objWithGetters), "Should correctly identify object with getter properties"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Partial interface implementation", + test: () => { + // Test object that has some but not all required properties/methods + const partialImplementation = { + size: 0, + droppedAttributes: 0, + clear: () => {}, + get: () => {}, + has: () => {}, + set: () => {} + // Missing: del, keys, entries, forEach, values, child, listen, id, attributes + }; + + Assert.ok(!isAttributeContainer(partialImplementation), "Should return false for partial implementation"); + + // Test object with all methods but wrong property types + const wrongPropertyTypes = { + size: null, + droppedAttributes: undefined, + attributes: null, + clear: [], + get: () => {}, + has: () => {}, + set: () => {}, + del: () => {}, + keys: () => {}, + entries: () => {}, + forEach: () => {}, + values: () => {}, + child: () => {}, + listen: () => {} + }; + + Assert.ok(!isAttributeContainer(wrongPropertyTypes), "Should return false when properties are null/undefined"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Edge cases and complex objects", + test: () => { + // Test function object (functions are objects in JavaScript) + const func = function() {}; + func.size = 0; + func.droppedAttributes = 0; + func.attributes = {}; + func.id = "test-id"; + func.clear = () => {}; + func.get = () => {}; + func.has = () => {}; + func.set = () => {}; + func.del = () => {}; + func.keys = () => {}; + func.entries = () => {}; + func.forEach = () => {}; + func.values = () => {}; + func.child = () => {}; + func.listen = () => {}; + + Assert.ok(isAttributeContainer(func), "Should identify function object with all required properties"); + + // Test class instance + class MockContainer { + size = 10; + droppedAttributes = 1; + attributes = {}; + id = "mock-id"; + clear() {} + get() {} + has() {} + set() {} + del() {} + keys() {} + entries() {} + forEach() {} + values() {} + child() {} + listen() {} + } + + const mockInstance = new MockContainer(); + Assert.ok(isAttributeContainer(mockInstance), "Should identify class instance with all required properties"); + + // Test object with prototype chain + const prototypeObj = Object.create({ + clear: () => {}, + get: () => {}, + has: () => {} + }); + prototypeObj.size = 0; + prototypeObj.droppedAttributes = 0; + prototypeObj.attributes = {}; + prototypeObj.id = "proto-id"; + prototypeObj.set = () => {}; + prototypeObj.del = () => {}; + prototypeObj.keys = () => {}; + prototypeObj.entries = () => {}; + prototypeObj.forEach = () => {}; + prototypeObj.values = () => {}; + prototypeObj.child = () => {}; + prototypeObj.listen = () => {}; + + Assert.ok(isAttributeContainer(prototypeObj), "Should identify object with methods in prototype chain"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Type guard functionality", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + const notContainer = { some: "object" }; + + // Test type narrowing works correctly + function processContainer(obj: any) { + if (isAttributeContainer(obj)) { + // In this block, TypeScript should know obj is IAttributeContainer + return obj.size; // This should compile without errors + } + return -1; + } + + Assert.equal(0, processContainer(container), "Should return container size for valid container"); + Assert.equal(-1, processContainer(notContainer), "Should return -1 for invalid container"); + Assert.equal(-1, processContainer(null), "Should return -1 for null"); + Assert.equal(-1, processContainer("string"), "Should return -1 for string"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Real container usage scenarios", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test container after operations + const container = createAttributeContainer(otelCfg, "test-container"); + container.set("key1", "value1"); + container.set("key2", 42); + + Assert.ok(isAttributeContainer(container), "Should identify container after adding attributes"); + + // Test container after clear + container.clear(); + Assert.ok(isAttributeContainer(container), "Should still identify container after clear"); + + // Test container with inheritance + const inheritedAttribs: IOTelAttributes = { + "parent.key1": "value1", + "parent.key2": "value2" + }; + const containerWithInheritance = createAttributeContainer(otelCfg, "test-inherited", inheritedAttribs); + Assert.ok(isAttributeContainer(containerWithInheritance), "Should identify container with inheritance"); + + // Test container after inheritance operations + containerWithInheritance.set("child.key", "child_value"); + Assert.ok(isAttributeContainer(containerWithInheritance), "Should identify container after inheritance operations"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Does not access lazy properties", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create a mock object that has all the required methods and properties + // but will throw if the lazy properties are accessed + const mockContainer = { + id: "mock-container-id", // Add required id property + clear: () => {}, + get: () => undefined, + has: () => false, + set: () => true, + del: () => false, + keys: () => { + return { + next: () => ({ done: true, value: undefined }) + } as Iterator; + }, + entries: () => { + return { + next: () => ({ done: true, value: undefined }) + } as Iterator<[string, any, any]>; + }, + forEach: () => {}, + values: () => { + return { + next: () => ({ done: true, value: undefined }) + } as Iterator; + }, + child: () => ({}), // Add missing child method + listen: () => ({ rm: () => {} }) + }; + + // Add properties that will throw if accessed via getter + let sizeAccessed = false; + let droppedAttributesAccessed = false; + let attributesAccessed = false; + + Object.defineProperty(mockContainer, "size", { + get: () => { + sizeAccessed = true; + throw new Error("size property should not be accessed in isAttributeContainer"); + }, + enumerable: false, + configurable: true + }); + + Object.defineProperty(mockContainer, "droppedAttributes", { + get: () => { + droppedAttributesAccessed = true; + throw new Error("droppedAttributes property should not be accessed in isAttributeContainer"); + }, + enumerable: false, + configurable: true + }); + + Object.defineProperty(mockContainer, "attributes", { + get: () => { + attributesAccessed = true; + throw new Error("attributes property should not be accessed in isAttributeContainer"); + }, + enumerable: false, + configurable: true + }); + + // Test that isAttributeContainer does not access the lazy properties + let result: boolean; + let errorThrown = false; + try { + result = isAttributeContainer(mockContainer); + } catch (error) { + errorThrown = true; + // If an error was thrown, it means a lazy property was accessed + } + + // Verify no error was thrown (meaning no lazy properties were accessed) + Assert.ok(!errorThrown, "isAttributeContainer should not access lazy properties"); + + // Verify the function works correctly + Assert.ok(result!, "Should correctly identify as attribute container"); + + // Verify that none of the lazy properties were accessed + Assert.ok(!sizeAccessed, "size property should not be accessed during isAttributeContainer check"); + Assert.ok(!droppedAttributesAccessed, "droppedAttributes property should not be accessed during isAttributeContainer check"); + Assert.ok(!attributesAccessed, "attributes property should not be accessed during isAttributeContainer check"); + } + }); + + this.testCase({ + name: "isAttributeContainer: Lazy properties behavior verification", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create a real container + const container = createAttributeContainer(otelCfg, "test-container"); + container.set("test.key", "test_value"); + + // Test that the lazy properties exist (using 'in' operator which doesn't trigger getters) + Assert.ok("size" in container, "Container should have size property"); + Assert.ok("droppedAttributes" in container, "Container should have droppedAttributes property"); + Assert.ok("attributes" in container, "Container should have attributes property"); + + // Verify isAttributeContainer works with real container + Assert.ok(isAttributeContainer(container), "Should identify real container correctly"); + + // Now verify the properties actually work when accessed + Assert.equal(1, container.size, "Size should be 1"); + Assert.equal(0, container.droppedAttributes, "DroppedAttributes should be 0"); + Assert.equal(1, Object.keys(container.attributes).length, "Attributes should have 1 key"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container-to-container inheritance - basic functionality", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create parent container with some attributes + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("parent.key2", "parent_value2"); + parentContainer.set("shared.key", "parent_shared"); + + // Create child container with parent container as inheritance source + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + + // Test inherited attributes are accessible from parent container + Assert.equal("parent_value1", childContainer.get("parent.key1"), "Should get inherited attribute from parent container"); + Assert.equal("parent_value2", childContainer.get("parent.key2"), "Should get second inherited attribute from parent container"); + Assert.equal("parent_shared", childContainer.get("shared.key"), "Should get shared inherited attribute from parent container"); + Assert.ok(childContainer.has("parent.key1"), "Should report inherited attribute from parent container as existing"); + Assert.equal(3, childContainer.size, "Should count inherited attributes from parent container in size"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container-to-container inheritance - override behavior", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create parent container + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("shared.key", "parent_shared"); + parentContainer.set("override.me", "parent_override"); + + // Create child container with parent container as inheritance source + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + + // Test initial inherited state + Assert.equal(3, childContainer.size, "Should start with 3 inherited attributes from parent container"); + Assert.equal("parent_shared", childContainer.get("shared.key"), "Should get inherited value from parent container initially"); + + // Test overriding inherited attribute from parent container + Assert.ok(childContainer.set("shared.key", "child_shared"), "Should successfully override inherited attribute from parent container"); + Assert.equal("child_shared", childContainer.get("shared.key"), "Should get overridden value instead of parent container value"); + Assert.equal(3, childContainer.size, "Size should remain 3 after override of parent container attribute"); + + // Test adding new attribute alongside inherited ones from parent container + Assert.ok(childContainer.set("child.key", "child_value"), "Should add new attribute to child container"); + Assert.equal("child_value", childContainer.get("child.key"), "Should get new child attribute value"); + Assert.equal("parent_value1", childContainer.get("parent.key1"), "Should still get inherited attribute from parent container"); + Assert.equal(4, childContainer.size, "Size should be 4 after adding new attribute to child container"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container-to-container inheritance - iterator methods", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create parent container + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("parent.key2", "parent_value2"); + parentContainer.set("shared.key", "parent_shared"); + + // Create child container with parent container as inheritance source + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + + // Add some child attributes + childContainer.set("child.key", "child_value"); + childContainer.set("shared.key", "child_shared"); // Override inherited from parent container + + // Test keys iterator includes both inherited from parent container and child keys + const keys: string[] = []; + const keysIter = childContainer.keys(); + let keysResult = keysIter.next(); + while (!keysResult.done) { + keys.push(keysResult.value); + keysResult = keysIter.next(); + } + Assert.equal(4, keys.length, "Should have 4 total keys (2 child + 2 non-overridden from parent container)"); + Assert.ok(keys.includes("child.key"), "Should include child key"); + Assert.ok(keys.includes("shared.key"), "Should include overridden key"); + Assert.ok(keys.includes("parent.key1"), "Should include inherited key1 from parent container"); + Assert.ok(keys.includes("parent.key2"), "Should include inherited key2 from parent container"); + + // Test entries iterator with parent container inheritance + const entries: [string, any, eAttributeFilter][] = []; + const entriesIter = childContainer.entries(); + let entriesResult = entriesIter.next(); + while (!entriesResult.done) { + entries.push(entriesResult.value); + entriesResult = entriesIter.next(); + } + Assert.equal(4, entries.length, "Should have 4 total entries including parent container attributes"); + + // Convert to map by extracting key-value pairs (ignoring source) + const entryMap = new Map(entries.map(entry => [entry[0], entry[1]])); + Assert.equal("child_value", entryMap.get("child.key"), "Should have child entry"); + Assert.equal("child_shared", entryMap.get("shared.key"), "Should have overridden value in entries"); + Assert.equal("parent_value1", entryMap.get("parent.key1"), "Should have inherited entry from parent container"); + + // Test forEach with parent container inheritance + const forEachResults: { [key: string]: any } = {}; + childContainer.forEach((key, value) => { + forEachResults[key] = value; + }); + Assert.equal(4, objKeys(forEachResults).length, "forEach should iterate over all items including parent container attributes"); + Assert.equal("child_shared", forEachResults["shared.key"], "forEach should use overridden value"); + Assert.equal("parent_value1", forEachResults["parent.key1"], "forEach should include inherited value from parent container"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container-to-container inheritance - attributes includes parent container", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create parent container + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("parent.key2", 42); + parentContainer.set("shared.key", "parent_shared"); + + // Create child container with parent container as inheritance source + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + + childContainer.set("child.key", "child_value"); + childContainer.set("shared.key", "child_shared"); // Override parent container value + + const attributes = childContainer.attributes; + Assert.equal("parent_value1", attributes["parent.key1"], "Should include inherited string from parent container"); + Assert.equal(42, attributes["parent.key2"], "Should include inherited number from parent container"); + Assert.equal("child_value", attributes["child.key"], "Should include child attribute"); + Assert.equal("child_shared", attributes["shared.key"], "Should use overridden value instead of parent container value"); + Assert.equal(4, objKeys(attributes).length, "Should have correct total count including parent container attributes"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container-to-container inheritance - clear behavior", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create parent container + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("parent.key2", "parent_value2"); + + // Create child container with parent container as inheritance source + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + + // Verify inherited attributes from parent container are present + Assert.equal(2, childContainer.size, "Should start with 2 inherited attributes from parent container"); + Assert.equal("parent_value1", childContainer.get("parent.key1"), "Should access inherited attribute from parent container"); + + // Add child attribute + childContainer.set("child.key", "child_value"); + Assert.equal(3, childContainer.size, "Should have 3 total attributes"); + + // Clear should remove everything including inherited from parent container + childContainer.clear(); + Assert.equal(0, childContainer.size, "Should have 0 attributes after clear"); + Assert.ok(!childContainer.has("parent.key1"), "Should not have inherited attribute from parent container after clear"); + Assert.ok(!childContainer.has("child.key"), "Should not have child attribute after clear"); + Assert.equal(undefined, childContainer.get("parent.key1"), "Should return undefined for inherited attribute from parent container after clear"); + + // Verify parent container is unaffected by child clear + Assert.equal(2, parentContainer.size, "Parent container should still have its attributes after child clear"); + Assert.equal("parent_value1", parentContainer.get("parent.key1"), "Parent container should still have its values after child clear"); + } + }); + + this.testCase({ + name: "AttributeContainer: Multi-level container inheritance - container with IOTelAttributes parent", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create grandparent attributes (IOTelAttributes) + const grandparentAttribs: IOTelAttributes = { + "grandparent.key1": "grandparent_value1", + "grandparent.key2": "grandparent_value2", + "shared.all": "grandparent_shared" + }; + + // Create parent container with grandparent attributes as inheritance + const parentContainer = createAttributeContainer(otelCfg, "test-grandparent", grandparentAttribs); + parentContainer.set("parent.key", "parent_value"); + parentContainer.set("shared.all", "parent_shared"); // Override grandparent + + // Create child container with parent container as inheritance source + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + childContainer.set("child.key", "child_value"); + childContainer.set("shared.all", "child_shared"); // Override parent (and grandparent) + + // Test access to all levels + Assert.equal("grandparent_value1", childContainer.get("grandparent.key1"), "Should access grandparent attribute through parent container"); + Assert.equal("grandparent_value2", childContainer.get("grandparent.key2"), "Should access second grandparent attribute through parent container"); + Assert.equal("parent_value", childContainer.get("parent.key"), "Should access parent attribute"); + Assert.equal("child_value", childContainer.get("child.key"), "Should access child attribute"); + Assert.equal("child_shared", childContainer.get("shared.all"), "Should get child override value"); + + // Test size includes all levels + Assert.equal(5, childContainer.size, "Should count attributes from all levels (2 grandparent + 1 parent + 1 child + 1 override)"); + + // Test iterator includes all levels + const allKeys: string[] = []; + childContainer.forEach((key, value) => { + allKeys.push(key); + }); + Assert.equal(5, allKeys.length, "forEach should iterate over all levels"); + Assert.ok(allKeys.includes("grandparent.key1"), "Should include grandparent key1"); + Assert.ok(allKeys.includes("grandparent.key2"), "Should include grandparent key2"); + Assert.ok(allKeys.includes("parent.key"), "Should include parent key"); + Assert.ok(allKeys.includes("child.key"), "Should include child key"); + Assert.ok(allKeys.includes("shared.all"), "Should include overridden key"); + } + }); + + this.testCase({ + name: "AttributeContainer: Multi-level container inheritance - container with container parent", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create great-grandparent container + const greatGrandparentContainer = createAttributeContainer(otelCfg, "test-container"); + greatGrandparentContainer.set("great.key", "great_value"); + greatGrandparentContainer.set("shared.multi", "great_shared"); + + // Create grandparent container inheriting from great-grandparent + const grandparentContainer = createAttributeContainer(otelCfg, "test-grandparent", greatGrandparentContainer); + grandparentContainer.set("grandparent.key", "grandparent_value"); + grandparentContainer.set("shared.multi", "grandparent_shared"); // Override great-grandparent + + // Create parent container inheriting from grandparent + const parentContainer = createAttributeContainer(otelCfg, "test-parent", grandparentContainer); + parentContainer.set("parent.key", "parent_value"); + parentContainer.set("shared.multi", "parent_shared"); // Override grandparent + + // Create child container inheriting from parent + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + childContainer.set("child.key", "child_value"); + + // Test access through the inheritance chain + Assert.equal("great_value", childContainer.get("great.key"), "Should access great-grandparent through inheritance chain"); + Assert.equal("grandparent_value", childContainer.get("grandparent.key"), "Should access grandparent through inheritance chain"); + Assert.equal("parent_value", childContainer.get("parent.key"), "Should access parent through inheritance chain"); + Assert.equal("child_value", childContainer.get("child.key"), "Should access child attribute"); + Assert.equal("parent_shared", childContainer.get("shared.multi"), "Should get most recent override in chain"); + + // Test size counts inheritance chain correctly + Assert.equal(5, childContainer.size, "Should count all unique attributes in inheritance chain"); + + // Test that modifications to ancestor containers are reflected + greatGrandparentContainer.set("new.great.key", "new_great_value"); + Assert.equal("new_great_value", childContainer.get("new.great.key"), "Should access newly added great-grandparent attribute"); + Assert.equal(6, childContainer.size, "Size should increase after ancestor modification"); + } + }); + + this.testCase({ + name: "AttributeContainer: Mixed inheritance types - IOTelAttributes and container combinations", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test 1: IOTelAttributes -> Container -> Container + const baseAttribs: IOTelAttributes = { + "base.key1": "base_value1", + "base.key2": "base_value2", + "shared.mixed": "base_shared" + }; + + const intermediateContainer = createAttributeContainer(otelCfg, "test-base", baseAttribs); + intermediateContainer.set("intermediate.key", "intermediate_value"); + intermediateContainer.set("shared.mixed", "intermediate_shared"); + + const finalContainer = createAttributeContainer(otelCfg, "test-final", intermediateContainer); + finalContainer.set("final.key", "final_value"); + + Assert.equal("base_value1", finalContainer.get("base.key1"), "Should access base IOTelAttributes through container"); + Assert.equal("base_value2", finalContainer.get("base.key2"), "Should access second base attribute through container"); + Assert.equal("intermediate_value", finalContainer.get("intermediate.key"), "Should access intermediate container attribute"); + Assert.equal("final_value", finalContainer.get("final.key"), "Should access final container attribute"); + Assert.equal("intermediate_shared", finalContainer.get("shared.mixed"), "Should get intermediate override of base"); + Assert.equal(5, finalContainer.size, "Should count all attributes through mixed inheritance"); + + // Test 2: Container -> IOTelAttributes (should not work - IOTelAttributes can't inherit from container) + // This tests that addAttributes works with containers + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + sourceContainer.set("source.key1", "source_value1"); + sourceContainer.set("source.key2", "source_value2"); + + const targetContainer = createAttributeContainer(otelCfg, "test-container"); + addAttributes(targetContainer, sourceContainer); + + Assert.equal("source_value1", targetContainer.get("source.key1"), "Should copy container attributes via addAttributes"); + Assert.equal("source_value2", targetContainer.get("source.key2"), "Should copy second container attribute via addAttributes"); + Assert.equal(2, targetContainer.size, "Should have correct size after adding container attributes"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container inheritance edge cases", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test empty parent container + const emptyParentContainer = createAttributeContainer(otelCfg, "test-container"); + const childOfEmpty = createAttributeContainer(otelCfg, "test-child-empty", emptyParentContainer); + + Assert.equal(0, childOfEmpty.size, "Should have 0 size with empty parent container"); + childOfEmpty.set("child.key", "child_value"); + Assert.equal(1, childOfEmpty.size, "Should have 1 size after adding to child with empty parent container"); + Assert.equal("child_value", childOfEmpty.get("child.key"), "Should get child attribute with empty parent container"); + + // Test parent container that gets modified after child creation + const dynamicParent = createAttributeContainer(otelCfg, "test-container"); + const dynamicChild = createAttributeContainer(otelCfg, "test-dynamic-child", dynamicParent); + + Assert.equal(0, dynamicChild.size, "Should start with 0 size"); + + // Modify parent after child creation + dynamicParent.set("dynamic.key", "dynamic_value"); + Assert.equal("dynamic_value", dynamicChild.get("dynamic.key"), "Should access dynamically added parent attribute"); + Assert.equal(1, dynamicChild.size, "Should reflect parent modifications in size"); + + // Test parent container that gets cleared after child creation + dynamicParent.clear(); + Assert.equal(undefined, dynamicChild.get("dynamic.key"), "Should not access cleared parent attributes"); + Assert.equal(0, dynamicChild.size, "Should reflect parent clear in size"); + + // Test circular reference prevention (child shouldn't be able to inherit from itself) + const selfContainer = createAttributeContainer(otelCfg, "test-container"); + selfContainer.set("self.key", "self_value"); + // Note: This doesn't create actual circular reference as the inheritance is captured at creation time + const pseudoCircular = createAttributeContainer(otelCfg, "test-pseudo-circular", selfContainer); + pseudoCircular.set("pseudo.key", "pseudo_value"); + + Assert.equal("self_value", pseudoCircular.get("self.key"), "Should access parent attribute"); + Assert.equal("pseudo_value", pseudoCircular.get("pseudo.key"), "Should access own attribute"); + Assert.equal(2, pseudoCircular.size, "Should have correct size with pseudo-circular setup"); + } + }); + + this.testCase({ + name: "AttributeContainer: Container inheritance with addAttributes function", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create source container with inheritance + const baseAttribs: IOTelAttributes = { + "base.key": "base_value" + }; + const sourceContainer = createAttributeContainer(otelCfg, "test-base", baseAttribs); + sourceContainer.set("source.key1", "source_value1"); + sourceContainer.set("source.key2", "source_value2"); + + // Create target container + const targetContainer = createAttributeContainer(otelCfg, "test-container"); + targetContainer.set("target.existing", "existing_value"); + + // Use addAttributes to copy from source container (which has inheritance) + addAttributes(targetContainer, sourceContainer); + + // Verify all attributes from source container (including inherited) are copied + Assert.equal("base_value", targetContainer.get("base.key"), "Should copy inherited attribute from source container"); + Assert.equal("source_value1", targetContainer.get("source.key1"), "Should copy source container attribute 1"); + Assert.equal("source_value2", targetContainer.get("source.key2"), "Should copy source container attribute 2"); + Assert.equal("existing_value", targetContainer.get("target.existing"), "Should preserve existing target attributes"); + Assert.equal(4, targetContainer.size, "Should have correct size after adding container with inheritance"); + + // Test addAttributes with container that has container inheritance + const grandparentContainer = createAttributeContainer(otelCfg, "test-container"); + grandparentContainer.set("grandparent.key", "grandparent_value"); + + const parentContainer = createAttributeContainer(otelCfg, "test-parent", grandparentContainer); + parentContainer.set("parent.key", "parent_value"); + + const newTargetContainer = createAttributeContainer(otelCfg, "test-container"); + addAttributes(newTargetContainer, parentContainer); + + Assert.equal("grandparent_value", newTargetContainer.get("grandparent.key"), "Should copy multi-level inherited attribute"); + Assert.equal("parent_value", newTargetContainer.get("parent.key"), "Should copy parent container attribute"); + Assert.equal(2, newTargetContainer.size, "Should have correct size after adding multi-level container"); + } + }); + + // ===== createSnapshotAttributes Tests ===== + + this.testCase({ + name: "createSnapshotAttributes: Invalid arguments - null and undefined", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test null source + const nullResult = createAttributeSnapshot(otelCfg, "null-test", null as any); + Assert.ok(isAttributeContainer(nullResult), "Should return container for null source"); + Assert.equal(0, nullResult.size, "Should return empty container for null source"); + + // Test undefined source + const undefinedResult = createAttributeSnapshot(otelCfg, "undefined-test", undefined as any); + Assert.ok(isAttributeContainer(undefinedResult), "Should return container for undefined source"); + Assert.equal(0, undefinedResult.size, "Should return empty container for undefined source"); + + // Verify containers are functional + Assert.ok(nullResult.set("test.key", "test_value"), "Null result container should be functional"); + Assert.equal("test_value", nullResult.get("test.key"), "Null result container should store values"); + + Assert.ok(undefinedResult.set("test.key", "test_value"), "Undefined result container should be functional"); + Assert.equal("test_value", undefinedResult.get("test.key"), "Undefined result container should store values"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Invalid arguments - primitive types", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test with primitive types (should create empty containers) + const stringResult = createAttributeSnapshot(otelCfg, "string-test", "string" as any); + Assert.ok(isAttributeContainer(stringResult), "Should return container for string"); + Assert.equal(0, stringResult.size, "Should return empty container for string"); + + const numberResult = createAttributeSnapshot(otelCfg, "number-test", 123 as any); + Assert.ok(isAttributeContainer(numberResult), "Should return container for number"); + Assert.equal(0, numberResult.size, "Should return empty container for number"); + + const booleanResult = createAttributeSnapshot(otelCfg, "boolean-test", true as any); + Assert.ok(isAttributeContainer(booleanResult), "Should return container for boolean"); + Assert.equal(0, booleanResult.size, "Should return empty container for boolean"); + + // Verify containers are functional + Assert.ok(stringResult.set("test.key", "test_value"), "String result container should be functional"); + Assert.ok(numberResult.set("test.key", "test_value"), "Number result container should be functional"); + Assert.ok(booleanResult.set("test.key", "test_value"), "Boolean result container should be functional"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IOTelAttributes - immediate deep copy", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceAttribs: IOTelAttributes = { + "service.name": "test-service", + "service.version": "1.0.0", + "deployment.environment": "production", + "request.id": "req-123", + "user.authenticated": true, + "request.size": 1024 + }; + + const snapshotContainer = createAttributeSnapshot(otelCfg, "iotel-snapshot", sourceAttribs); + + // Verify all attributes are copied + Assert.ok(isAttributeContainer(snapshotContainer), "Should return attribute container"); + Assert.equal(6, snapshotContainer.size, "Should have all source attributes"); + Assert.equal("test-service", snapshotContainer.get("service.name"), "Should copy string attribute"); + Assert.equal("1.0.0", snapshotContainer.get("service.version"), "Should copy version string"); + Assert.equal("production", snapshotContainer.get("deployment.environment"), "Should copy environment"); + Assert.equal("req-123", snapshotContainer.get("request.id"), "Should copy request ID"); + Assert.equal(true, snapshotContainer.get("user.authenticated"), "Should copy boolean attribute"); + Assert.equal(1024, snapshotContainer.get("request.size"), "Should copy number attribute"); + + // Verify immutability - changes to source don't affect copy + sourceAttribs["service.name"] = "modified-service"; + sourceAttribs["new.key"] = "new_value"; + delete sourceAttribs["request.id"]; + + Assert.equal("test-service", snapshotContainer.get("service.name"), "Should remain unchanged after source modification"); + Assert.equal(undefined, snapshotContainer.get("new.key"), "Should not have new key added to source"); + Assert.equal("req-123", snapshotContainer.get("request.id"), "Should still have deleted key"); + Assert.equal(6, snapshotContainer.size, "Size should remain unchanged after source modifications"); + + // Verify the snapshot container can be modified independently + Assert.ok(snapshotContainer.set("snapshot.key", "snapshot_value"), "Should be able to add to snapshot container"); + Assert.equal("snapshot_value", snapshotContainer.get("snapshot.key"), "Should get newly added attribute"); + Assert.equal(7, snapshotContainer.size, "Size should increase after adding to snapshot container"); + Assert.equal(undefined, sourceAttribs["snapshot.key"], "Source should not have snapshot container additions"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IOTelAttributes - empty and single attribute", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test empty attributes + const emptyAttribs: IOTelAttributes = {}; + const emptySnapshot = createAttributeSnapshot(otelCfg, "empty-snapshot", emptyAttribs); + Assert.equal(0, emptySnapshot.size, "Should create empty container for empty attributes"); + + // Verify independence + emptyAttribs["added.later"] = "added_value"; + Assert.equal(0, emptySnapshot.size, "Empty snapshot should remain empty after source modification"); + Assert.equal(undefined, emptySnapshot.get("added.later"), "Empty snapshot should not have added attributes"); + + // Test single attribute + const singleAttrib: IOTelAttributes = { "single.key": "single_value" }; + const singleSnapshot = createAttributeSnapshot(otelCfg, "single-snapshot", singleAttrib); + Assert.equal(1, singleSnapshot.size, "Should have single attribute"); + Assert.equal("single_value", singleSnapshot.get("single.key"), "Should get single attribute value"); + + // Verify independence + singleAttrib["single.key"] = "modified_value"; + singleAttrib["second.key"] = "second_value"; + Assert.equal("single_value", singleSnapshot.get("single.key"), "Single snapshot should retain original value"); + Assert.equal(undefined, singleSnapshot.get("second.key"), "Single snapshot should not have added attributes"); + Assert.equal(1, singleSnapshot.size, "Single snapshot size should remain 1"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IOTelAttributes - complex hierarchical keys", + test: () => { + const otelCfg: IOTelConfig = {}; + const complexAttribs: IOTelAttributes = { + "http.request.method": "GET", + "http.request.url": "https://api.example.com/users", + "http.request.headers.user-agent": "Test-Agent/1.0", + "http.request.headers.authorization": "Bearer token123", + "http.response.status_code": 200, + "http.response.headers.content-type": "application/json", + "span.attributes.custom.nested.deep.value": "deep_nested", + "user.session.id": "session-456", + "user.session.start_time": "2023-01-01T00:00:00Z" + }; + + const snapshotContainer = createAttributeSnapshot(otelCfg, "complex-snapshot", complexAttribs); + + // Verify all hierarchical attributes are copied correctly + Assert.equal(9, snapshotContainer.size, "Should have all complex attributes"); + Assert.equal("GET", snapshotContainer.get("http.request.method"), "Should copy HTTP method"); + Assert.equal("https://api.example.com/users", snapshotContainer.get("http.request.url"), "Should copy HTTP URL"); + Assert.equal("Test-Agent/1.0", snapshotContainer.get("http.request.headers.user-agent"), "Should copy deeply nested header"); + Assert.equal("Bearer token123", snapshotContainer.get("http.request.headers.authorization"), "Should copy auth header"); + Assert.equal(200, snapshotContainer.get("http.response.status_code"), "Should copy status code"); + Assert.equal("application/json", snapshotContainer.get("http.response.headers.content-type"), "Should copy content type"); + Assert.equal("deep_nested", snapshotContainer.get("span.attributes.custom.nested.deep.value"), "Should copy very deep nested value"); + Assert.equal("session-456", snapshotContainer.get("user.session.id"), "Should copy session ID"); + Assert.equal("2023-01-01T00:00:00Z", snapshotContainer.get("user.session.start_time"), "Should copy session start time"); + + // Test immutability with complex modifications + complexAttribs["http.request.method"] = "POST"; + complexAttribs["http.request.headers.new-header"] = "new_value"; + delete complexAttribs["user.session.start_time"]; + + Assert.equal("GET", snapshotContainer.get("http.request.method"), "Should retain original HTTP method"); + Assert.equal(undefined, snapshotContainer.get("http.request.headers.new-header"), "Should not have new header"); + Assert.equal("2023-01-01T00:00:00Z", snapshotContainer.get("user.session.start_time"), "Should retain deleted attribute"); + Assert.equal(9, snapshotContainer.size, "Complex snapshot size should remain unchanged"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - basic copy-on-change", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + // Set up initial state + sourceContainer.set("initial.key1", "initial_value1"); + sourceContainer.set("initial.key2", "initial_value2"); + sourceContainer.set("shared.key", "original_shared"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "basic-container-snapshot", sourceContainer); + + // Verify initial state is copied + Assert.ok(isAttributeContainer(snapshotContainer), "Should return attribute container"); + Assert.equal(3, snapshotContainer.size, "Should have all initial attributes"); + Assert.equal("initial_value1", snapshotContainer.get("initial.key1"), "Should have initial key1"); + Assert.equal("initial_value2", snapshotContainer.get("initial.key2"), "Should have initial key2"); + Assert.equal("original_shared", snapshotContainer.get("shared.key"), "Should have shared key"); + + // Modify source container after snapshot creation + sourceContainer.set("shared.key", "modified_shared"); + sourceContainer.set("new.key", "new_value"); + + // Verify snapshot container shows pre-change values (lazy copy-on-change) + Assert.equal("original_shared", snapshotContainer.get("shared.key"), "Should retain original shared value due to lazy copy-on-change"); + Assert.equal(undefined, snapshotContainer.get("new.key"), "Should not have new key added to source"); + Assert.equal(3, snapshotContainer.size, "Size should remain unchanged after source modifications"); + + // Verify snapshot container can be modified independently + Assert.ok(snapshotContainer.set("snapshot.only", "snapshot_value"), "Should be able to modify snapshot container"); + Assert.equal("snapshot_value", snapshotContainer.get("snapshot.only"), "Should get snapshot-only value"); + Assert.equal(4, snapshotContainer.size, "Snapshot size should increase"); + Assert.equal(undefined, sourceContainer.get("snapshot.only"), "Source should not have snapshot additions"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - with inheritance", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create source container with inheritance + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2", + "shared.inherited": "parent_shared" + }; + const sourceContainer = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + sourceContainer.set("source.key", "source_value"); + sourceContainer.set("shared.inherited", "source_override"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "inheritance-snapshot", sourceContainer); + + // Verify all attributes including inherited are captured + Assert.equal(4, snapshotContainer.size, "Should capture all attributes including inherited"); + Assert.equal("parent_value1", snapshotContainer.get("parent.key1"), "Should capture inherited key1"); + Assert.equal("parent_value2", snapshotContainer.get("parent.key2"), "Should capture inherited key2"); + Assert.equal("source_value", snapshotContainer.get("source.key"), "Should capture source key"); + Assert.equal("source_override", snapshotContainer.get("shared.inherited"), "Should capture overridden value"); + + // Modify source container (both own and inherited attributes) + sourceContainer.set("parent.key1", "modified_parent1"); + sourceContainer.set("source.key", "modified_source"); + sourceContainer.set("new.source.key", "new_source_value"); + + // Verify snapshot container retains original state + Assert.equal("parent_value1", snapshotContainer.get("parent.key1"), "Should retain original inherited value"); + Assert.equal("source_value", snapshotContainer.get("source.key"), "Should retain original source value"); + Assert.equal("source_override", snapshotContainer.get("shared.inherited"), "Should retain original override"); + Assert.equal(undefined, snapshotContainer.get("new.source.key"), "Should not have new source attributes"); + Assert.equal(4, snapshotContainer.size, "Size should remain unchanged"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - complex inheritance chain", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create a complex inheritance chain + const grandparentAttribs: IOTelAttributes = { + "grandparent.key1": "grandparent_value1", + "grandparent.key2": "grandparent_value2", + "shared.multi": "grandparent_shared" + }; + + const parentContainer = createAttributeContainer(otelCfg, "test-grandparent", grandparentAttribs); + parentContainer.set("parent.key", "parent_value"); + parentContainer.set("shared.multi", "parent_override"); + + const sourceContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + sourceContainer.set("source.key", "source_value"); + sourceContainer.set("shared.multi", "source_override"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "complex-inheritance-snapshot", sourceContainer); + + // Verify full inheritance chain is captured + Assert.equal(5, snapshotContainer.size, "Should capture entire inheritance chain"); + Assert.equal("grandparent_value1", snapshotContainer.get("grandparent.key1"), "Should capture grandparent key1"); + Assert.equal("grandparent_value2", snapshotContainer.get("grandparent.key2"), "Should capture grandparent key2"); + Assert.equal("parent_value", snapshotContainer.get("parent.key"), "Should capture parent key"); + Assert.equal("source_value", snapshotContainer.get("source.key"), "Should capture source key"); + Assert.equal("source_override", snapshotContainer.get("shared.multi"), "Should capture final override"); + + // Modify multiple levels of the inheritance chain + parentContainer.set("parent.key", "modified_parent"); + parentContainer.set("new.parent.key", "new_parent_value"); + sourceContainer.set("source.key", "modified_source"); + sourceContainer.set("grandparent.key1", "modified_grandparent"); // Override inherited + sourceContainer.set("new.source.key", "new_source_value"); + + // Verify snapshot container retains original state from all levels + Assert.equal("grandparent_value1", snapshotContainer.get("grandparent.key1"), "Should retain original grandparent value"); + Assert.equal("parent_value", snapshotContainer.get("parent.key"), "Should retain original parent value"); + Assert.equal("source_value", snapshotContainer.get("source.key"), "Should retain original source value"); + Assert.equal(undefined, snapshotContainer.get("new.parent.key"), "Should not have new parent attributes"); + Assert.equal(undefined, snapshotContainer.get("new.source.key"), "Should not have new source attributes"); + Assert.equal(5, snapshotContainer.size, "Size should remain unchanged after complex modifications"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - empty container", + test: () => { + const otelCfg: IOTelConfig = {}; + const emptyContainer = createAttributeContainer(otelCfg, "test-container"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "empty-container-snapshot", emptyContainer); + + // Verify empty state + Assert.equal(0, snapshotContainer.size, "Should create empty snapshot container"); + Assert.equal(undefined, snapshotContainer.get("any.key"), "Should not have any attributes"); + + // Modify source after snapshot creation + emptyContainer.set("added.later", "added_value"); + emptyContainer.set("another.key", "another_value"); + + // Verify snapshot remains empty + Assert.equal(0, snapshotContainer.size, "Snapshot should remain empty"); + Assert.equal(undefined, snapshotContainer.get("added.later"), "Should not have later additions"); + Assert.equal(undefined, snapshotContainer.get("another.key"), "Should not have later additions"); + + // Verify snapshot can be modified independently + Assert.ok(snapshotContainer.set("snapshot.key", "snapshot_value"), "Should be able to modify empty snapshot"); + Assert.equal("snapshot_value", snapshotContainer.get("snapshot.key"), "Should get snapshot value"); + Assert.equal(1, snapshotContainer.size, "Snapshot size should increase"); + Assert.equal(undefined, emptyContainer.get("snapshot.key"), "Source should not have snapshot additions"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - source container clear operation", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + // Set up initial state + sourceContainer.set("key1", "value1"); + sourceContainer.set("key2", "value2"); + sourceContainer.set("key3", "value3"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Verify initial state + Assert.equal(3, snapshotContainer.size, "Should have all initial attributes"); + Assert.equal("value1", snapshotContainer.get("key1"), "Should have key1"); + Assert.equal("value2", snapshotContainer.get("key2"), "Should have key2"); + Assert.equal("value3", snapshotContainer.get("key3"), "Should have key3"); + + // Clear source container + sourceContainer.clear(); + + // Verify snapshot container preserves pre-clear state + Assert.equal(3, snapshotContainer.size, "Should retain all attributes after source clear"); + Assert.equal("value1", snapshotContainer.get("key1"), "Should retain key1 after source clear"); + Assert.equal("value2", snapshotContainer.get("key2"), "Should retain key2 after source clear"); + Assert.equal("value3", snapshotContainer.get("key3"), "Should retain key3 after source clear"); + + // Add new attributes to cleared source + sourceContainer.set("new.key", "new_value"); + sourceContainer.set("key1", "new_value1"); // Same key, different value + + // Verify snapshot container is unaffected by post-clear additions + Assert.equal(3, snapshotContainer.size, "Size should remain unchanged after post-clear additions"); + Assert.equal("value1", snapshotContainer.get("key1"), "Should retain original key1 value"); + Assert.equal(undefined, snapshotContainer.get("new.key"), "Should not have post-clear additions"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - with inherited container that changes", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create parent container that will be modified + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("parent.key2", "parent_value2"); + + // Create source container with parent inheritance + const sourceContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + sourceContainer.set("source.key", "source_value"); + sourceContainer.set("parent.key1", "source_override"); // Override parent + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Verify initial state includes inheritance + Assert.equal(3, snapshotContainer.size, "Should have all attributes including inherited"); + Assert.equal("source_override", snapshotContainer.get("parent.key1"), "Should have source override"); + Assert.equal("parent_value2", snapshotContainer.get("parent.key2"), "Should have inherited parent key2"); + Assert.equal("source_value", snapshotContainer.get("source.key"), "Should have source key"); + + // Modify parent container after snapshot creation + parentContainer.set("parent.key1", "modified_parent1"); + parentContainer.set("parent.key2", "modified_parent2"); + parentContainer.set("new.parent.key", "new_parent_value"); + + // Modify source container + sourceContainer.set("source.key", "modified_source"); + sourceContainer.set("new.source.key", "new_source_value"); + + // Verify snapshot container retains original state despite parent and source changes + Assert.equal("source_override", snapshotContainer.get("parent.key1"), "Should retain original override"); + Assert.equal("parent_value2", snapshotContainer.get("parent.key2"), "Should retain original inherited value"); + Assert.equal("source_value", snapshotContainer.get("source.key"), "Should retain original source value"); + Assert.equal(undefined, snapshotContainer.get("new.parent.key"), "Should not have new parent attributes"); + Assert.equal(undefined, snapshotContainer.get("new.source.key"), "Should not have new source attributes"); + Assert.equal(3, snapshotContainer.size, "Size should remain unchanged"); + + // Clear parent container + parentContainer.clear(); + + // Verify snapshot container still retains original state + Assert.equal("source_override", snapshotContainer.get("parent.key1"), "Should retain override after parent clear"); + Assert.equal("parent_value2", snapshotContainer.get("parent.key2"), "Should retain inherited value after parent clear"); + Assert.equal("source_value", snapshotContainer.get("source.key"), "Should retain source value after parent clear"); + Assert.equal(3, snapshotContainer.size, "Size should remain unchanged after parent clear"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - multiple snapshot copies", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + // Set up initial state + sourceContainer.set("shared.key", "initial_value"); + sourceContainer.set("common.attr", "common_value"); + + // Create first snapshot copy + const snapshot1 = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Modify source + sourceContainer.set("shared.key", "modified_value"); + sourceContainer.set("new.key", "new_value"); + + // Create second snapshot copy after modification + const snapshot2 = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Verify both snapshot copies are independent + Assert.equal("initial_value", snapshot1.get("shared.key"), "First snapshot should have initial value"); + Assert.equal("modified_value", snapshot2.get("shared.key"), "Second snapshot should have modified value"); + + Assert.equal(undefined, snapshot1.get("new.key"), "First snapshot should not have new key"); + Assert.equal("new_value", snapshot2.get("new.key"), "Second snapshot should have new key"); + + Assert.equal(2, snapshot1.size, "First snapshot should have 2 attributes"); + Assert.equal(3, snapshot2.size, "Second snapshot should have 3 attributes"); + + // Modify both snapshot copies independently + snapshot1.set("snapshot1.key", "snapshot1_value"); + snapshot2.set("snapshot2.key", "snapshot2_value"); + + // Verify independence + Assert.equal("snapshot1_value", snapshot1.get("snapshot1.key"), "First snapshot should have its own addition"); + Assert.equal(undefined, snapshot2.get("snapshot1.key"), "Second snapshot should not have first's addition"); + Assert.equal(undefined, snapshot1.get("snapshot2.key"), "First snapshot should not have second's addition"); + Assert.equal("snapshot2_value", snapshot2.get("snapshot2.key"), "Second snapshot should have its own addition"); + + Assert.equal(3, snapshot1.size, "First snapshot size should be 3"); + Assert.equal(4, snapshot2.size, "Second snapshot size should be 4"); + + // Verify source container is unaffected by snapshot modifications + Assert.equal(undefined, sourceContainer.get("snapshot1.key"), "Source should not have first snapshot's addition"); + Assert.equal(undefined, sourceContainer.get("snapshot2.key"), "Source should not have second snapshot's addition"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: IAttributeContainer - iterators work correctly", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + // Set up diverse attribute types + sourceContainer.set("string.attr", "string_value"); + sourceContainer.set("number.attr", 42); + sourceContainer.set("boolean.attr", true); + sourceContainer.set("nested.deep.attr", "nested_value"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Test keys iterator + const keys: string[] = []; + const keysIter = snapshotContainer.keys(); + let keyResult = keysIter.next(); + while (!keyResult.done) { + keys.push(keyResult.value); + keyResult = keysIter.next(); + } + Assert.equal(4, keys.length, "Should iterate over all keys"); + Assert.ok(keys.includes("string.attr"), "Should include string attribute key"); + Assert.ok(keys.includes("number.attr"), "Should include number attribute key"); + Assert.ok(keys.includes("boolean.attr"), "Should include boolean attribute key"); + Assert.ok(keys.includes("nested.deep.attr"), "Should include nested attribute key"); + + // Test entries iterator + const entries: [string, any, eAttributeFilter][] = []; + const entriesIter = snapshotContainer.entries(); + let entryResult = entriesIter.next(); + while (!entryResult.done) { + entries.push(entryResult.value); + entryResult = entriesIter.next(); + } + Assert.equal(4, entries.length, "Should iterate over all entries"); + + // Convert to map by extracting key-value pairs (ignoring source) + const entryMap = new Map(entries.map(entry => [entry[0], entry[1]])); + Assert.equal("string_value", entryMap.get("string.attr"), "Should have correct string entry"); + Assert.equal(42, entryMap.get("number.attr"), "Should have correct number entry"); + Assert.equal(true, entryMap.get("boolean.attr"), "Should have correct boolean entry"); + Assert.equal("nested_value", entryMap.get("nested.deep.attr"), "Should have correct nested entry"); + + // Test values iterator + const values: any[] = []; + const valuesIter = snapshotContainer.values(); + let valueResult = valuesIter.next(); + while (!valueResult.done) { + values.push(valueResult.value); + valueResult = valuesIter.next(); + } + Assert.equal(4, values.length, "Should iterate over all values"); + Assert.ok(values.includes("string_value"), "Should include string value"); + Assert.ok(values.includes(42), "Should include number value"); + Assert.ok(values.includes(true), "Should include boolean value"); + Assert.ok(values.includes("nested_value"), "Should include nested value"); + + // Test forEach + const forEachResults: { [key: string]: any } = {}; + snapshotContainer.forEach((key, value) => { + forEachResults[key] = value; + }); + Assert.equal(4, objKeys(forEachResults).length, "forEach should process all attributes"); + Assert.equal("string_value", forEachResults["string.attr"], "forEach should provide correct string value"); + Assert.equal(42, forEachResults["number.attr"], "forEach should provide correct number value"); + Assert.equal(true, forEachResults["boolean.attr"], "forEach should provide correct boolean value"); + Assert.equal("nested_value", forEachResults["nested.deep.attr"], "forEach should provide correct nested value"); + + // Test attributes + const attributes = snapshotContainer.attributes; + Assert.equal(4, objKeys(attributes).length, "attributes should include all attributes"); + Assert.equal("string_value", attributes["string.attr"], "attributes should have correct string attribute"); + Assert.equal(42, attributes["number.attr"], "attributes should have correct number attribute"); + Assert.equal(true, attributes["boolean.attr"], "attributes should have correct boolean attribute"); + Assert.equal("nested_value", attributes["nested.deep.attr"], "attributes should have correct nested attribute"); + + // Modify source and verify snapshot iterators are unaffected + sourceContainer.set("string.attr", "modified_string"); + sourceContainer.set("new.attr", "new_value"); + + const postModifyKeys: string[] = []; + const postModifyKeysIter = snapshotContainer.keys(); + let postModifyKeyResult = postModifyKeysIter.next(); + while (!postModifyKeyResult.done) { + postModifyKeys.push(postModifyKeyResult.value); + postModifyKeyResult = postModifyKeysIter.next(); + } + Assert.equal(4, postModifyKeys.length, "Should still have 4 keys after source modification"); + Assert.ok(!postModifyKeys.includes("new.attr"), "Should not include new source attribute"); + + const postModifyAttributes = snapshotContainer.attributes; + Assert.equal("string_value", postModifyAttributes["string.attr"], "Should retain original string value"); + Assert.equal(undefined, postModifyAttributes["new.attr"], "Should not have new source attribute"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Mixed types - IOTelAttributes vs IAttributeContainer behavior", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create equivalent data in both forms + const sourceAttribs: IOTelAttributes = { + "service.name": "test-service", + "service.version": "1.0.0", + "shared.key": "shared_value" + }; + + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + sourceContainer.set("service.name", "test-service"); + sourceContainer.set("service.version", "1.0.0"); + sourceContainer.set("shared.key", "shared_value"); + + // Create snapshot versions + const snapshotFromAttribs = createAttributeSnapshot(otelCfg, "test-snapshot", sourceAttribs); + const snapshotFromContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Verify both have equivalent initial state + Assert.equal(3, snapshotFromAttribs.size, "IOTelAttributes snapshot should have 3 attributes"); + Assert.equal(3, snapshotFromContainer.size, "IAttributeContainer snapshot should have 3 attributes"); + + Assert.equal("test-service", snapshotFromAttribs.get("service.name"), "IOTelAttributes snapshot should have service name"); + Assert.equal("test-service", snapshotFromContainer.get("service.name"), "IAttributeContainer snapshot should have service name"); + + Assert.equal("shared_value", snapshotFromAttribs.get("shared.key"), "IOTelAttributes snapshot should have shared key"); + Assert.equal("shared_value", snapshotFromContainer.get("shared.key"), "IAttributeContainer snapshot should have shared key"); + + // Modify sources differently + sourceAttribs["service.name"] = "modified-attribs-service"; + sourceAttribs["attribs.only"] = "attribs_only_value"; + + sourceContainer.set("service.name", "modified-container-service"); + sourceContainer.set("container.only", "container_only_value"); + + // Verify different immutability behaviors + + // IOTelAttributes snapshot should be unaffected (immediate copy) + Assert.equal("test-service", snapshotFromAttribs.get("service.name"), "IOTelAttributes snapshot should retain original value"); + Assert.equal(undefined, snapshotFromAttribs.get("attribs.only"), "IOTelAttributes snapshot should not have new attribs"); + Assert.equal(3, snapshotFromAttribs.size, "IOTelAttributes snapshot size should remain 3"); + + // IAttributeContainer snapshot should be unaffected (copy-on-change) + Assert.equal("test-service", snapshotFromContainer.get("service.name"), "IAttributeContainer snapshot should retain original value"); + Assert.equal(undefined, snapshotFromContainer.get("container.only"), "IAttributeContainer snapshot should not have new container attrs"); + Assert.equal(3, snapshotFromContainer.size, "IAttributeContainer snapshot size should remain 3"); + + // Both snapshot containers should function identically for modifications + snapshotFromAttribs.set("snapshot.attribs", "attribs_snapshot_value"); + snapshotFromContainer.set("snapshot.container", "container_snapshot_value"); + + Assert.equal("attribs_snapshot_value", snapshotFromAttribs.get("snapshot.attribs"), "IOTelAttributes snapshot should store new value"); + Assert.equal("container_snapshot_value", snapshotFromContainer.get("snapshot.container"), "IAttributeContainer snapshot should store new value"); + + Assert.equal(4, snapshotFromAttribs.size, "IOTelAttributes snapshot size should increase to 4"); + Assert.equal(4, snapshotFromContainer.size, "IAttributeContainer snapshot size should increase to 4"); + + // Verify sources are unaffected by snapshot modifications + Assert.equal(undefined, sourceAttribs["snapshot.attribs"], "Source attribs should not have snapshot addition"); + Assert.equal(undefined, sourceContainer.get("snapshot.container"), "Source container should not have snapshot addition"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Stress test - large attribute sets", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create large attribute set + let largeAttribs = createAttributeContainer(otelCfg, "largeAttribs"); + for (let i = 0; i < 100; i++) { + largeAttribs.set(`attr.${i}.key`, `value_${i}`); + largeAttribs.set(`nested.level1.level2.attr${i}`, i); + largeAttribs.set(`boolean.attr.${i}`, i % 2 === 0); + } + + const snapshotContainer = createAttributeSnapshot(otelCfg, "large-snapshot", largeAttribs); + + // Verify all attributes are captured + Assert.equal(128, snapshotContainer.size, "Should have all 128 large attributes"); + Assert.equal(172, snapshotContainer.droppedAttributes, "As the size should hanve been limited the dropped count should reflect the excess attributes"); + + // Spot check various types + Assert.equal("value_0", snapshotContainer.get("attr.0.key"), "Should have first string attribute"); + Assert.equal("value_42", snapshotContainer.get("attr.42.key"), "Should have last string attribute"); + Assert.equal(0, snapshotContainer.get("nested.level1.level2.attr0"), "Should have first number attribute"); + Assert.equal(42, snapshotContainer.get("nested.level1.level2.attr42"), "Should have last number attribute"); + Assert.equal(true, snapshotContainer.get("boolean.attr.0"), "Should have first boolean (true)"); + Assert.equal(false, snapshotContainer.get("boolean.attr.1"), "Should have second boolean (false)"); + Assert.equal(false, snapshotContainer.get("boolean.attr.41"), "Should have second-to-last boolean (false)"); + + // Modify large source + for (let i = 0; i < 100; i++) { + largeAttribs.set(`attr.${i}.key`, `modified_value_${i}`); + largeAttribs.set(`new.attr.${i}`, `new_value_${i}`); + } + + // Verify snapshot container retains original values + Assert.equal(128, snapshotContainer.size, "Size should remain 128 after large source modification"); + Assert.equal(329, snapshotContainer.droppedAttributes, "As the size should hanve been limited the dropped count should reflect the excess attributes"); + Assert.equal("value_0", snapshotContainer.get("attr.0.key"), "Should retain original first value"); + Assert.equal("value_42", snapshotContainer.get("attr.42.key"), "Should retain original last value"); + Assert.equal(undefined, snapshotContainer.get("new.attr.0"), "Should not have new attributes"); + Assert.equal(undefined, snapshotContainer.get("new.attr.99"), "Should not have new attributes"); + + // Test iterator performance with large set + let keyCount = 0; + snapshotContainer.forEach((key, value) => { + keyCount++; + }); + Assert.equal(128, keyCount, "forEach should iterate over all 300 attributes"); + + const attributes = snapshotContainer.attributes; + Assert.equal(128, objKeys(attributes).length, "attributes should return all 300 attributes"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Edge cases - malformed and special inputs", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Test with attribute objects that have non-string keys (should be handled gracefully) + const weirdAttribs = { + "normal.key": "normal_value", + 123: "numeric_key_value", // This may or may not be included depending on implementation + "": "empty_key_value", + " ": "space_key_value", + "key with spaces": "spaces_value", + "key.with.many.dots.in.it": "many_dots_value" + } as any; + + const snapshotContainer = createAttributeSnapshot(otelCfg, "weird-snapshot", weirdAttribs); + + // Verify normal attributes are handled + Assert.equal("normal_value", snapshotContainer.get("normal.key"), "Should handle normal key"); + Assert.equal("spaces_value", snapshotContainer.get("key with spaces"), "Should handle keys with spaces"); + Assert.equal("many_dots_value", snapshotContainer.get("key.with.many.dots.in.it"), "Should handle keys with many dots"); + + // Test edge case values + const edgeValueAttribs: IOTelAttributes = { + "null.value": null as any, + "undefined.value": undefined as any, + "zero.number": 0, + "false.boolean": false, + "empty.string": "", + "very.long.string": "a".repeat(1000), + "negative.number": -42, + "float.number": 3.14159 + }; + + const edgeSnapshotContainer = createAttributeSnapshot(otelCfg, "edge-snapshot", edgeValueAttribs); + + // Verify edge case values are handled correctly + Assert.equal(null, edgeSnapshotContainer.get("null.value"), "Should handle null value"); + Assert.equal(undefined, edgeSnapshotContainer.get("undefined.value"), "Should handle undefined value"); + Assert.equal(0, edgeSnapshotContainer.get("zero.number"), "Should handle zero value"); + Assert.equal(false, edgeSnapshotContainer.get("false.boolean"), "Should handle false value"); + Assert.equal("", edgeSnapshotContainer.get("empty.string"), "Should handle empty string"); + Assert.equal("a".repeat(1000), edgeSnapshotContainer.get("very.long.string"), "Should handle very long string"); + Assert.equal(-42, edgeSnapshotContainer.get("negative.number"), "Should handle negative number"); + Assert.equal(3.14159, edgeSnapshotContainer.get("float.number"), "Should handle float number"); + + // Test with array-like object (should create empty container) + const arrayLike = ["item1", "item2", "item3"] as any; + const arraySnapshotContainer = createAttributeSnapshot(otelCfg, "array-snapshot", arrayLike); + Assert.equal(0, arraySnapshotContainer.size, "Should create empty container for array-like input"); + + // Test with function object which has properties assigned + const funcObj = function() { return "test"; } as any; + funcObj.customProp = "custom_value"; + const funcSnapshotContainer = createAttributeSnapshot(otelCfg, "func-snapshot", funcObj); + Assert.equal(1, funcSnapshotContainer.size, "Should create container for function input"); + Assert.equal("custom_value", funcSnapshotContainer.get("customProp"), "Should include function property"); + + // Test with function object (should create a container with it's properties) + const funcObj2 = function() { return "test"; } as any; + const func2SnapshotContainer = createAttributeSnapshot(otelCfg, "func2-snapshot", funcObj2); + Assert.equal(0, func2SnapshotContainer.size, "Should create empty container for function input"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Lazy copy-on-change with eAttributeFilter behavior", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + // Set up initial state + sourceContainer.set("original.key1", "original_value1"); + sourceContainer.set("shared.key", "original_shared"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Before any source changes, snapshot should access inherited values + Assert.equal("original_value1", snapshotContainer.get("original.key1"), "Should get inherited value"); + Assert.equal("original_shared", snapshotContainer.get("shared.key"), "Should get inherited shared value"); + + // Test eAttributeFilter behavior before changes + Assert.ok(snapshotContainer.has("original.key1"), "Should have key (default - includes inherited)"); + Assert.ok(!snapshotContainer.has("original.key1", eAttributeFilter.Local), "Should NOT have key locally before source change"); + Assert.ok(snapshotContainer.has("original.key1", eAttributeFilter.Inherited), "Should have key as inherited before source change"); + + // Now modify the source - this should trigger lazy copy-on-change + sourceContainer.set("shared.key", "modified_shared"); + sourceContainer.set("new.key", "new_value"); + + // After source change, snapshot should preserve original values via local copies + Assert.equal("original_shared", snapshotContainer.get("shared.key"), "Should retain original shared value after source change"); + Assert.equal(undefined, snapshotContainer.get("new.key"), "Should not have new key added after snapshot"); + + // Test eAttributeFilter behavior after changes + Assert.ok(snapshotContainer.has("shared.key"), "Should have shared key (default - includes both local and inherited)"); + Assert.ok(snapshotContainer.has("shared.key", eAttributeFilter.Local), "Should have shared key locally after source change (lazy copy)"); + Assert.ok(snapshotContainer.has("shared.key", eAttributeFilter.Inherited), "Should still have an inherited shared key even after copied locally"); + Assert.equal("original_shared", snapshotContainer.get("shared.key"), "Should return the local copy of shared.key"); + Assert.equal("original_shared", snapshotContainer.get("shared.key", eAttributeFilter.Local), "Should return the local copy of shared.key"); + Assert.equal("original_shared", snapshotContainer.get("shared.key", eAttributeFilter.LocalOrDeleted), "Should return the local copy of shared.key"); + Assert.equal("modified_shared", snapshotContainer.get("shared.key", eAttributeFilter.Inherited), "Should return the inherited copy of shared.key"); + + // Keys that weren't changed should still be inherited + Assert.ok(snapshotContainer.has("original.key1"), "Should still have unchanged key"); + Assert.ok(!snapshotContainer.has("original.key1", eAttributeFilter.Local), "Unchanged key should still NOT be local"); + Assert.ok(snapshotContainer.has("original.key1", eAttributeFilter.Inherited), "Unchanged key should still be inherited"); + + // Test clear operation behavior + sourceContainer.clear(); + + // After source clear, all original keys should be copied locally to preserve snapshot + Assert.equal("original_value1", snapshotContainer.get("original.key1"), "Should still have original key1 after source clear"); + Assert.equal("original_shared", snapshotContainer.get("shared.key"), "Should still have original shared after source clear"); + + // All keys should now be local (copied due to clear operation) + Assert.ok(snapshotContainer.has("original.key1", eAttributeFilter.Local), "Key should be local after source clear"); + Assert.ok(!snapshotContainer.has("original.key1", eAttributeFilter.Inherited), "Key should NOT be inherited after source clear"); + Assert.ok(snapshotContainer.has("shared.key", eAttributeFilter.Local), "Shared key should be local after source clear"); + Assert.ok(!snapshotContainer.has("shared.key", eAttributeFilter.Inherited), "Shared key should NOT be inherited after source clear"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Iterator behavior with source tracking", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + sourceContainer.set("inherited.key1", "inherited_value1"); + sourceContainer.set("will.change", "original_value"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Add some local keys to snapshot + snapshotContainer.set("local.key1", "local_value1"); + + // Trigger lazy copy by changing source + sourceContainer.set("will.change", "changed_value"); + + // Collect entries with source information + let entries: [string, any, number][] = []; + let entriesIter = snapshotContainer.entries(); + let entryResult = entriesIter.next(); + while (!entryResult.done) { + entries.push(entryResult.value); + entryResult = entriesIter.next(); + } + + // Should have 3 entries: 1 inherited + 1 local copy due to change + 1 purely local + Assert.equal(3, entries.length, "Should have 3 entries total"); + + // Find each entry and verify source + let inheritedEntry = entries.find(e => e[0] === "inherited.key1"); + let changedEntry = entries.find(e => e[0] === "will.change"); + let localEntry = entries.find(e => e[0] === "local.key1"); + + Assert.ok(inheritedEntry, "Should have inherited entry"); + Assert.equal(eAttributeFilter.Inherited, inheritedEntry![2], "inherited.key1 should be marked as inherited"); + + Assert.ok(changedEntry, "Should have changed entry"); + Assert.equal(eAttributeFilter.Local, changedEntry![2], "will.change should be marked as local (lazy copied)"); + Assert.equal("original_value", changedEntry![1], "will.change should have original value"); + + Assert.ok(localEntry, "Should have local entry"); + Assert.equal(eAttributeFilter.Local, localEntry![2], "local.key1 should be marked as local"); + } + }); + + // ===== Delete Functionality Tests ===== + + this.testCase({ + name: "AttributeContainer: Delete functionality - basic delete", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + + // Set some attributes + Assert.ok(container.set("key1", "value1"), "Should set key1"); + Assert.ok(container.set("key2", "value2"), "Should set key2"); + Assert.equal(2, container.size, "Should have 2 attributes"); + + // Delete one attribute + Assert.ok(container.del("key1"), "Should delete key1"); + Assert.ok(!container.has("key1"), "key1 should not exist after delete"); + Assert.equal(undefined, container.get("key1"), "key1 should return undefined after delete"); + Assert.equal("value2", container.get("key2"), "key2 should still exist"); + Assert.equal(1, container.size, "Should have 1 attribute after delete"); + + // Try to delete non-existent key + Assert.ok(!container.del("nonexistent"), "Should return false for non-existent key"); + + // Try to delete already deleted key + Assert.ok(!container.del("key1"), "Should return false for already deleted key"); + } + }); + + this.testCase({ + name: "AttributeContainer: Delete functionality - delete inherited attribute", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2", + "shared.key": "parent_shared" + }; + + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + container.set("local.key", "local_value"); + + // Verify initial state + Assert.equal(4, container.size, "Should have 4 attributes (3 inherited + 1 local)"); + Assert.equal("parent_value1", container.get("parent.key1"), "Should get inherited value"); + Assert.ok(container.has("parent.key1"), "Should have inherited key"); + + // Delete inherited attribute + Assert.ok(container.del("parent.key1"), "Should delete inherited attribute"); + Assert.ok(!container.has("parent.key1"), "Inherited key should not exist after delete"); + Assert.equal(undefined, container.get("parent.key1"), "Deleted inherited key should return undefined"); + Assert.equal(3, container.size, "Size should decrease after deleting inherited key"); + + // Other inherited attributes should still be accessible + Assert.equal("parent_value2", container.get("parent.key2"), "Other inherited attributes should remain"); + Assert.equal("local_value", container.get("local.key"), "Local attributes should remain"); + + // Delete should only affect this container, not the source + const newContainer = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + Assert.equal("parent_value1", newContainer.get("parent.key1"), "Source attributes should be unaffected"); + } + }); + + this.testCase({ + name: "AttributeContainer: Delete functionality - with container inheritance", + test: () => { + const otelCfg: IOTelConfig = {}; + + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("parent.key2", "parent_value2"); + + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + childContainer.set("child.key", "child_value"); + + // Verify initial state + Assert.equal(3, childContainer.size, "Should have 3 attributes"); + Assert.equal("parent_value1", childContainer.get("parent.key1"), "Should get inherited from parent container"); + + // Delete inherited attribute from parent container + Assert.ok(childContainer.del("parent.key1"), "Should delete inherited attribute from parent container"); + Assert.ok(!childContainer.has("parent.key1"), "Deleted inherited key should not exist"); + Assert.equal(undefined, childContainer.get("parent.key1"), "Deleted inherited key should return undefined"); + Assert.equal(2, childContainer.size, "Size should decrease"); + + // Parent container should still have the attribute + Assert.equal("parent_value1", parentContainer.get("parent.key1"), "Parent container should be unaffected"); + Assert.equal(2, parentContainer.size, "Parent container size should be unchanged"); + + // Child can still access other inherited attributes + Assert.equal("parent_value2", childContainer.get("parent.key2"), "Other inherited attributes should remain"); + } + }); + + this.testCase({ + name: "AttributeContainer: Delete functionality - iterator excludes deleted inherited keys", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2", + "parent.key3": "parent_value3" + }; + + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + container.set("local.key", "local_value"); + + // Delete one inherited key + Assert.ok(container.del("parent.key2"), "Should delete inherited key"); + + // Collect all keys via iterator + const keys: string[] = []; + container.forEach((key, value) => { + keys.push(key); + }); + + Assert.equal(3, keys.length, "Should iterate over 3 keys (2 inherited + 1 local, excluding deleted)"); + Assert.ok(keys.includes("parent.key1"), "Should include non-deleted inherited key1"); + Assert.ok(!keys.includes("parent.key2"), "Should not include deleted inherited key2"); + Assert.ok(keys.includes("parent.key3"), "Should include non-deleted inherited key3"); + Assert.ok(keys.includes("local.key"), "Should include local key"); + + // Test entries iterator + const entries: [string, any, eAttributeFilter][] = []; + const entriesIter = container.entries(); + let result = entriesIter.next(); + while (!result.done) { + entries.push(result.value); + result = entriesIter.next(); + } + + Assert.equal(3, entries.length, "entries() should return 3 entries"); + const entryKeys = entries.map(e => e[0]); + Assert.ok(!entryKeys.includes("parent.key2"), "entries() should not include deleted inherited key"); + } + }); + + this.testCase({ + name: "AttributeContainer: Delete functionality - clear doesn't affect deleted key tracking", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2" + }; + + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + container.set("local.key", "local_value"); + + // Delete inherited key + Assert.ok(container.del("parent.key1"), "Should delete inherited key"); + Assert.ok(!container.has("parent.key1"), "Deleted key should not exist"); + + // Clear container + container.clear(); + Assert.equal(0, container.size, "Size should be 0 after clear"); + + // Deleted key tracking should be reset after clear + // Re-adding parent attributes should make the key available again + const newContainer = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + Assert.equal("parent_value1", newContainer.get("parent.key1"), "Key should be available in new container"); + } + }); + + // ===== Change Operation Type Tests ===== + + this.testCase({ + name: "AttributeContainer: Change operations - Add vs Change operations", + test: () => { + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test-container"); + + let lastChangeInfo: IAttributeChangeInfo | null = null; + const unloadHook = container.listen((changeInfo) => { + lastChangeInfo = changeInfo; + }); + + // Test Add operation (new key) + Assert.ok(container.set("new.key", "new_value"), "Should add new key"); + Assert.ok(lastChangeInfo, "Should have change info"); + Assert.equal(eAttributeChangeOp.Add, lastChangeInfo!.op, "Should be Add operation for new key"); + Assert.equal("new.key", lastChangeInfo!.k, "Should have correct key"); + Assert.equal("new_value", lastChangeInfo!.val, "Should have new value"); + Assert.equal(undefined, lastChangeInfo!.prev, "Should have no previous value for new key"); + + // Test Change operation (existing key) + lastChangeInfo = null; + Assert.ok(container.set("new.key", "updated_value"), "Should update existing key"); + Assert.ok(lastChangeInfo, "Should have change info for update"); + Assert.equal(eAttributeChangeOp.Set, lastChangeInfo!.op, "Should be Set operation for existing key"); + Assert.equal("new.key", lastChangeInfo!.k, "Should have correct key"); + Assert.equal("updated_value", lastChangeInfo!.val, "Should have updated value"); + Assert.equal("new_value", lastChangeInfo!.prev, "Should have previous value"); + + // Test Delete operation + lastChangeInfo = null; + Assert.ok(container.del("new.key"), "Should delete key"); + Assert.ok(lastChangeInfo, "Should have change info for delete"); + Assert.equal(eAttributeChangeOp.Delete, lastChangeInfo!.op, "Should be Delete operation"); + Assert.equal("new.key", lastChangeInfo!.k, "Should have correct key for delete"); + Assert.equal("updated_value", lastChangeInfo!.prev, "Should have previous value for delete"); + Assert.equal(undefined, lastChangeInfo!.val, "Should have no new value for delete"); + + // Test Clear operation + container.set("key1", "value1"); + container.set("key2", "value2"); + lastChangeInfo = null; + container.clear(); + Assert.ok(lastChangeInfo, "Should have change info for clear"); + Assert.equal(eAttributeChangeOp.Clear, lastChangeInfo!.op, "Should be Clear operation"); + Assert.equal(undefined, lastChangeInfo!.k, "Should have no key for clear"); + + unloadHook.rm(); + } + }); + + this.testCase({ + name: "AttributeContainer: Change operations - with inherited attributes", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "inherited.key": "inherited_value" + }; + + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + let lastChangeInfo: IAttributeChangeInfo | null = null; + const unloadHook = container.listen((changeInfo) => { + lastChangeInfo = changeInfo; + }); + + // Test overriding inherited key (should be Add operation since it's new locally) + Assert.ok(container.set("inherited.key", "override_value"), "Should override inherited key"); + Assert.ok(lastChangeInfo, "Should have change info"); + Assert.equal(eAttributeChangeOp.Set, lastChangeInfo!.op, "Should be Add operation when overriding inherited key"); + Assert.equal("inherited.key", lastChangeInfo!.k, "Should have correct key"); + Assert.equal("override_value", lastChangeInfo!.val, "Should have override value"); + Assert.equal("inherited_value", lastChangeInfo!.prev, "Should have no previous local value"); + + // Test changing the local override (should be Change operation) + lastChangeInfo = null; + Assert.ok(container.set("inherited.key", "new_override_value"), "Should change local override"); + Assert.ok(lastChangeInfo, "Should have change info for change"); + Assert.equal(eAttributeChangeOp.Set, lastChangeInfo!.op, "Should be Change operation for existing local key"); + Assert.equal("inherited.key", lastChangeInfo!.k, "Should have correct key"); + Assert.equal("new_override_value", lastChangeInfo!.val, "Should have new override value"); + Assert.equal("override_value", lastChangeInfo!.prev, "Should have previous local value"); + + unloadHook.rm(); + } + }); + + // ===== Snapshot Container with Delete Tests ===== + + this.testCase({ + name: "createSnapshotAttributes: Delete operations - source container delete after snapshot", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + // Set up initial state + sourceContainer.set("key1", "value1"); + sourceContainer.set("key2", "value2"); + sourceContainer.set("key3", "value3"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Verify initial state + Assert.equal(3, snapshotContainer.size, "Should have all initial attributes"); + Assert.equal("value1", snapshotContainer.get("key1"), "Should have key1"); + Assert.equal("value2", snapshotContainer.get("key2"), "Should have key2"); + + // Delete from source container after snapshot creation + Assert.ok(sourceContainer.del("key1"), "Should delete key1 from source"); + + // Verify snapshot container preserves pre-delete state (lazy copy-on-change) + Assert.equal("value1", snapshotContainer.get("key1"), "Should retain original key1 value due to lazy copy-on-change"); + Assert.ok(snapshotContainer.has("key1"), "Should still have key1 due to lazy copy-on-change"); + Assert.equal(3, snapshotContainer.size, "Size should remain unchanged after source delete"); + + // Verify source container shows the deletion + Assert.equal(undefined, sourceContainer.get("key1"), "Source should not have deleted key"); + Assert.equal(2, sourceContainer.size, "Source size should decrease after delete"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Delete operations - snapshot container delete", + test: () => { + const otelCfg: IOTelConfig = {}; + const sourceContainer = createAttributeContainer(otelCfg, "test-container"); + + // Set up initial state + sourceContainer.set("key1", "value1"); + sourceContainer.set("key2", "value2"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Verify initial state + Assert.equal(2, snapshotContainer.size, "Should have all initial attributes"); + + // Delete from snapshot container + Assert.ok(snapshotContainer.del("key1"), "Should delete key1 from snapshot"); + Assert.equal(undefined, snapshotContainer.get("key1"), "Snapshot should not have deleted key"); + Assert.ok(!snapshotContainer.has("key1"), "Snapshot should not have deleted key"); + Assert.equal(1, snapshotContainer.size, "Snapshot size should decrease after delete"); + + // Verify source container is unaffected + Assert.equal("value1", sourceContainer.get("key1"), "Source should still have key1"); + Assert.equal(2, sourceContainer.size, "Source size should be unchanged"); + } + }); + + this.testCase({ + name: "createSnapshotAttributes: Delete operations - with inherited attributes", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2" + }; + + const sourceContainer = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + sourceContainer.set("source.key", "source_value"); + + const snapshotContainer = createAttributeSnapshot(otelCfg, "test-snapshot", sourceContainer); + + // Verify initial state with inheritance + Assert.equal(3, snapshotContainer.size, "Should have all attributes including inherited"); + Assert.equal("parent_value1", snapshotContainer.get("parent.key1"), "Should access inherited attribute"); + + // Delete inherited attribute from source after snapshot + Assert.ok(sourceContainer.del("parent.key1"), "Should delete inherited attribute from source"); + + // Verify snapshot preserves the inherited attribute + Assert.equal("parent_value1", snapshotContainer.get("parent.key1"), "Snapshot should preserve inherited attribute"); + Assert.equal(3, snapshotContainer.size, "Snapshot size should remain unchanged"); + + // Delete inherited attribute from snapshot directly + Assert.ok(snapshotContainer.del("parent.key2"), "Should delete inherited attribute from snapshot"); + Assert.equal(undefined, snapshotContainer.get("parent.key2"), "Snapshot should not have deleted inherited attribute"); + Assert.equal(2, snapshotContainer.size, "Snapshot size should decrease"); + + // Source should still have the inherited attribute + Assert.equal("parent_value2", sourceContainer.get("parent.key2"), "Source should still have inherited attribute"); + } + }); + + // ===== Edge Case Tests ===== + + this.testCase({ + name: "AttributeContainer: Delete edge case - inherited key added after deletion", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create parent container that starts empty + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + + // Create child container with empty parent + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + + // Create snapshot of child before any keys exist + const snapshotContainer = createAttributeSnapshot(otelCfg, "child-snapshot", childContainer); + + // Delete a key that doesn't exist yet (preemptive deletion) + Assert.ok(!childContainer.del("future.key"), "Should not be able to delete non-existent key"); + Assert.ok(!childContainer.has("future.key"), "Child should not have the key after deletion"); + + // Now add the key to the parent (this is the edge case) + parentContainer.set("future.key", "parent_value"); + + // Child should still not see the key because it was preemptively deleted + Assert.ok(childContainer.has("future.key"), "Child should still have the key after parent addition as the deletion failed"); + Assert.equal("parent_value", childContainer.get("future.key"), "Child should not get the inherited value"); + + // Snapshot should also not see the key + Assert.ok(!snapshotContainer.has("future.key"), "Snapshot should not have the key as it didn't exist at the point of creation"); + Assert.equal(undefined, snapshotContainer.get("future.key"), "Snapshot should not get the inherited value"); + + // Parent should have the key + Assert.ok(parentContainer.has("future.key"), "Parent should have the key"); + Assert.equal("parent_value", parentContainer.get("future.key"), "Parent should get the value"); + + // New child container should see the inherited key + const newChildContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + Assert.ok(newChildContainer.has("future.key"), "New child should have inherited key"); + Assert.equal("parent_value", newChildContainer.get("future.key"), "New child should get inherited value"); + } + }); + + this.testCase({ + name: "AttributeContainer: Iterator performance - no extra _find calls", + test: () => { + const otelCfg: IOTelConfig = {}; + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2", + "parent.key3": "parent_value3" + }; + + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + container.set("local.key1", "local_value1"); + + // Delete some inherited keys + Assert.ok(container.del("parent.key2"), "Should delete inherited key"); + + // Iterate through all keys - this should use the optimized deletedKeys tracking + const keys: string[] = []; + let iterationCount = 0; + container.forEach((key, value) => { + keys.push(key); + iterationCount++; + }); + + // Should have 3 keys: parent.key1, parent.key3, local.key1 (excluding deleted parent.key2) + Assert.equal(3, keys.length, "Should iterate over correct number of keys"); + Assert.equal(3, iterationCount, "Should iterate exactly 3 times"); + Assert.ok(keys.includes("parent.key1"), "Should include non-deleted inherited key1"); + Assert.ok(!keys.includes("parent.key2"), "Should not include deleted inherited key2"); + Assert.ok(keys.includes("parent.key3"), "Should include non-deleted inherited key3"); + Assert.ok(keys.includes("local.key1"), "Should include local key"); + + // Test entries iterator as well + const entries: [string, any, eAttributeFilter][] = []; + const entriesIter = container.entries(); + let result = entriesIter.next(); + while (!result.done) { + entries.push(result.value); + result = entriesIter.next(); + } + + Assert.equal(3, entries.length, "entries() should return correct number of entries"); + const entryKeys = entries.map(e => e[0]); + Assert.ok(!entryKeys.includes("parent.key2"), "entries() should not include deleted inherited key"); + } + }); + + // ===== Comprehensive Multi-Level Inheritance Tests ===== + + this.testCase({ + name: "AttributeContainer: Deep inheritance chain - 5 levels with delete operations", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create 5-level inheritance chain + const level5Container = createAttributeContainer(otelCfg, "level5-container"); + level5Container.set("level5.key", "level5_value"); + level5Container.set("shared.key", "level5_shared"); + + const level4Container = createAttributeContainer(otelCfg, "level4-container", level5Container); + level4Container.set("level4.key", "level4_value"); + level4Container.set("shared.key", "level4_shared"); // Override level5 + + const level3Container = createAttributeContainer(otelCfg, "level3-container", level4Container); + level3Container.set("level3.key", "level3_value"); + + const level2Container = createAttributeContainer(otelCfg, "level2-container", level3Container); + level2Container.set("level2.key", "level2_value"); + level2Container.set("shared.key", "level2_shared"); // Override level4 + + const level1Container = createAttributeContainer(otelCfg, "level1-container", level2Container); + level1Container.set("level1.key", "level1_value"); + + // Verify full inheritance chain + Assert.equal(6, level1Container.size, "Should have 6 attributes from all levels"); + Assert.equal("level5_value", level1Container.get("level5.key"), "Should access level5 attribute"); + Assert.equal("level4_value", level1Container.get("level4.key"), "Should access level4 attribute"); + Assert.equal("level3_value", level1Container.get("level3.key"), "Should access level3 attribute"); + Assert.equal("level2_value", level1Container.get("level2.key"), "Should access level2 attribute"); + Assert.equal("level1_value", level1Container.get("level1.key"), "Should access level1 attribute"); + Assert.equal("level2_shared", level1Container.get("shared.key"), "Should get level2 override of shared key"); + + // Test delete operations across inheritance chain + Assert.ok(level1Container.del("level4.key"), "Should delete inherited level4 key"); + Assert.ok(!level1Container.has("level4.key"), "level4.key should not exist after delete"); + Assert.equal("level4_value", level4Container.get("level4.key"), "level4 container should still have the key"); + Assert.equal(5, level1Container.size, "Size should decrease after delete"); + + // Delete shared key that's overridden at current level + Assert.ok(level2Container.del("shared.key"), "Should delete overridden shared key"); + Assert.ok(!level2Container.has("shared.key"), "shared.key should not exist after delete"); + Assert.equal("level4_shared", level3Container.get("shared.key"), "level3 should now see level4 value"); + Assert.equal(undefined, level1Container.get("shared.key"), "level1 should not see the level4 value through inheritance as it was deleted at level 2"); + + // Test iterator with deleted keys + const keys: string[] = []; + level1Container.forEach((key, value) => { + keys.push(key); + }); + Assert.equal(4, keys.length, "Should iterate over 4 remaining keys"); + Assert.ok(!keys.includes("level4.key"), "Should not include deleted level4 key"); + Assert.ok(keys.includes("level5.key"), "Should include level5 key"); + } + }); + + this.testCase({ + name: "AttributeContainer: Complex inheritance with snapshot containers", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create inheritance chain + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key1", "parent_value1"); + parentContainer.set("parent.key2", "parent_value2"); + parentContainer.set("shared.key", "parent_shared"); + + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + childContainer.set("child.key", "child_value"); + childContainer.set("shared.key", "child_shared"); // Override parent + + // Create snapshot of child (which includes inheritance) + const snapshot = createAttributeSnapshot(otelCfg, "complex-inheritance-snapshot", childContainer); + + // Verify snapshot captures full inheritance + Assert.equal(4, snapshot.size, "Snapshot should have all inherited attributes"); + Assert.equal("parent_value1", snapshot.get("parent.key1"), "Snapshot should have parent key1"); + Assert.equal("child_shared", snapshot.get("shared.key"), "Snapshot should have child override"); + + // Delete inherited key from child + Assert.ok(childContainer.del("parent.key1"), "Should delete inherited key from child"); + + // Snapshot should preserve original state via lazy copy + Assert.equal("parent_value1", snapshot.get("parent.key1"), "Snapshot should preserve deleted inherited key"); + Assert.ok(snapshot.has("parent.key1", eAttributeFilter.Local), "Deleted key should be copied locally in snapshot"); + + // Test source filtering in snapshot after delete + Assert.ok(!snapshot.has("parent.key1", eAttributeFilter.Inherited), "Should not appear as inherited after lazy copy"); + Assert.ok(snapshot.has("parent.key2", eAttributeFilter.Inherited), "Non-deleted keys should remain inherited"); + + // Modify parent after snapshot - snapshot should copy lazily + parentContainer.set("parent.key2", "modified_parent2"); + Assert.equal("parent_value2", snapshot.get("parent.key2"), "Snapshot should preserve original value"); + Assert.ok(snapshot.has("parent.key2", eAttributeFilter.Local), "Modified inherited key should be copied locally"); + } + }); + + this.testCase({ + name: "AttributeContainer: Circular reference prevention and edge cases", + test: () => { + const otelCfg: IOTelConfig = {}; + + const container1 = createAttributeContainer(otelCfg, "test-container"); + container1.set("container1.key", "value1"); + + // Test with null/undefined inheritance + const containerWithNull = createAttributeContainer(otelCfg, "test-null", null as any); + Assert.equal(0, containerWithNull.size, "Should handle null inheritance"); + + const containerWithUndefined = createAttributeContainer(otelCfg, "test-undefined", undefined as any); + Assert.equal(0, containerWithUndefined.size, "Should handle undefined inheritance"); + + // Test with empty inheritance object + const emptyAttribs: IOTelAttributes = {}; + const containerWithEmpty = createAttributeContainer(otelCfg, "test-empty-attribs", emptyAttribs); + Assert.equal(0, containerWithEmpty.size, "Should handle empty inheritance object"); + + // Test with inheritance object containing undefined/null values + const attribsWithNulls: IOTelAttributes = { + "valid.key": "valid_value", + "null.key": null as any, + "undefined.key": undefined as any, + "empty.string": "", + "zero.value": 0 + }; + + const containerWithNulls = createAttributeContainer(otelCfg, "test-with-nulls", attribsWithNulls); + Assert.equal(5, containerWithNulls.size, "Should include all inheritance attributes including null/undefined"); + Assert.equal("valid_value", containerWithNulls.get("valid.key"), "Should get valid inherited value"); + Assert.equal(null, containerWithNulls.get("null.key"), "Should get null inherited value"); + Assert.equal(undefined, containerWithNulls.get("undefined.key"), "Should get undefined inherited value"); + Assert.equal("", containerWithNulls.get("empty.string"), "Should get empty string"); + Assert.equal(0, containerWithNulls.get("zero.value"), "Should get zero value"); + } + }); + + this.testCase({ + name: "AttributeContainer: Performance with large inheritance chains", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create a large inheritance chain + let currentContainer = createAttributeContainer(otelCfg, "test-container"); + currentContainer.set("root.key", "root_value"); + + // Build 10-level inheritance chain + for (let i = 1; i <= 10; i++) { + const newContainer = createAttributeContainer(otelCfg, `level-${i}-container`, currentContainer); + newContainer.set(`level${i}.key`, `level${i}_value`); + newContainer.set(`shared.level${i}`, `shared_value_${i}`); + currentContainer = newContainer; + } + + // Final container should have access to all levels + Assert.equal(21, currentContainer.size, "Should have all attributes from 10-level chain"); + Assert.equal("root_value", currentContainer.get("root.key"), "Should access root attribute"); + Assert.equal("level5_value", currentContainer.get("level5.key"), "Should access middle level"); + Assert.equal("level10_value", currentContainer.get("level10.key"), "Should access top level"); + + // Test performance of iteration over large inheritance + let count = 0; + currentContainer.forEach((key, value) => { + count++; + }); + Assert.equal(21, count, "Should iterate over all inherited attributes"); + + // Test delete performance in large chain + Assert.ok(currentContainer.del("level5.key"), "Should delete from middle of chain"); + Assert.equal(20, currentContainer.size, "Size should decrease after delete"); + + // Verify iteration still works correctly after delete + count = 0; + currentContainer.forEach((key, value) => { + count++; + }); + Assert.equal(20, count, "Should iterate correctly after delete"); + } + }); + + this.testCase({ + name: "AttributeContainer: Change listener propagation through inheritance", + test: () => { + const otelCfg: IOTelConfig = {}; + + const parentContainer = createAttributeContainer(otelCfg, "test-container"); + parentContainer.set("parent.key", "parent_value"); + + const childContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + childContainer.set("child.key", "child_value"); + + let childChangeInfo: IAttributeChangeInfo | null = null; + const childUnloadHook = childContainer.listen((changeInfo) => { + childChangeInfo = changeInfo; + }); + + let parentChangeInfo: IAttributeChangeInfo | null = null; + const parentUnloadHook = parentContainer.listen((changeInfo) => { + parentChangeInfo = changeInfo; + }); + + // Test parent change propagation to child + parentContainer.set("parent.key", "modified_parent"); + Assert.ok(parentChangeInfo, "Parent should receive change notification"); + Assert.equal(eAttributeChangeOp.Set, parentChangeInfo!.op, "Parent should see Set operation"); + + // Child should be notified about parent changes that don't conflict with local keys + parentContainer.set("new.parent.key", "new_parent_value"); + Assert.ok(childChangeInfo, "Child should receive notification for non-conflicting parent change"); + + // Test parent clear propagation + childChangeInfo = null; + parentContainer.clear(); + Assert.ok(childChangeInfo, "Child should receive clear notification"); + Assert.equal(eAttributeChangeOp.Clear, childChangeInfo!.op, "Child should see Clear operation"); + + // Test delete operations + parentContainer.set("parent.key", "restored_value"); + childChangeInfo = null; + Assert.ok(parentContainer.del("parent.key"), "Should delete parent key"); + Assert.ok(childChangeInfo, "Child should receive delete notification"); + Assert.equal(eAttributeChangeOp.Delete, childChangeInfo!.op, "Child should see Delete operation"); + + childUnloadHook.rm(); + parentUnloadHook.rm(); + } + }); + + this.testCase({ + name: "AttributeContainer: Edge case - delete non-existent keys with inheritance", + test: () => { + const otelCfg: IOTelConfig = {}; + + const parentAttribs: IOTelAttributes = { + "parent.key1": "parent_value1", + "parent.key2": "parent_value2" + }; + + const container = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + + // Test deleting non-existent key that doesn't exist anywhere + Assert.ok(!container.del("completely.nonexistent"), "Should return false for completely non-existent key"); + + // Test deleting inherited key (should return true and mark as deleted) + Assert.ok(container.del("parent.key1"), "Should return true for deleting inherited key"); + Assert.ok(!container.has("parent.key1"), "Inherited key should not exist after delete"); + + // Test deleting already deleted inherited key + Assert.ok(!container.del("parent.key1"), "Should return false for already deleted inherited key"); + + // Test deleting key that might be added to parent later (edge case) + Assert.ok(!container.del("future.key"), "Should return false for future key that doesn't exist yet"); + + // Add the key to parent after deletion mark + const parentContainer = createAttributeContainer(otelCfg, "test-with-inheritance", parentAttribs); + const newChildContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + + // Delete a key that doesn't exist yet + Assert.ok(!newChildContainer.del("future.added.key"), "Should return false for non-existent key"); + + // Add the key to parent after child marked it as deleted + parentContainer.set("future.added.key", "future_value"); + + // Child should still not see the key due to pre-deletion + Assert.ok(newChildContainer.has("future.added.key"), "Child should see key added even though we attempted to delete prior to it existing"); + Assert.equal("future_value", newChildContainer.get("future.added.key"), "Child should return value for prior deleted key"); + + // But new containers should see it + const anotherChildContainer = createAttributeContainer(otelCfg, "test-child", parentContainer); + Assert.equal("future_value", anotherChildContainer.get("future.added.key"), "New child should see parent key"); + } + }); + + this.testCase({ + name: "AttributeContainer: Complex snapshot interactions with deep inheritance", + test: () => { + const otelCfg: IOTelConfig = {}; + + // Create complex inheritance + const greatGrandparent = createAttributeContainer(otelCfg, "test-container"); + greatGrandparent.set("ggp.key", "ggp_value"); + greatGrandparent.set("shared.deep", "ggp_shared"); + + const grandparent = createAttributeContainer(otelCfg, "grandparent-container", greatGrandparent); + grandparent.set("gp.key", "gp_value"); + grandparent.set("shared.deep", "gp_shared"); + + const parent = createAttributeContainer(otelCfg, "parent-container", grandparent); + parent.set("p.key", "p_value"); + + const child = createAttributeContainer(otelCfg, "child-container", parent); + child.set("c.key", "c_value"); + child.set("shared.deep", "c_shared"); + + // Create snapshot of deep inheritance + const snapshot = createAttributeSnapshot(otelCfg, "deep-inheritance-snapshot", child); + + // Verify snapshot captures full inheritance chain + Assert.equal(5, snapshot.size, "Snapshot should capture all levels"); + Assert.equal("ggp_value", snapshot.get("ggp.key"), "Should capture great-grandparent"); + Assert.equal("gp_value", snapshot.get("gp.key"), "Should capture grandparent"); + Assert.equal("p_value", snapshot.get("p.key"), "Should capture parent"); + Assert.equal("c_value", snapshot.get("c.key"), "Should capture child"); + Assert.equal("c_shared", snapshot.get("shared.deep"), "Should capture child override"); + + // Delete at various levels after snapshot + Assert.ok(child.del("shared.deep"), "Child should delete its override"); + Assert.equal(undefined, child.get("shared.deep"), "Child still won't see the grand-parent as it's been deleted"); + Assert.ok(grandparent.del("gp.key"), "Grandparent should delete its key"); + greatGrandparent.clear(); // Clear great-grandparent + + // Snapshot should preserve all original values through lazy copying + Assert.equal("ggp_value", snapshot.get("ggp.key"), "Should preserve ggp key after clear"); + Assert.equal("gp_value", snapshot.get("gp.key"), "Should preserve gp key after delete"); + Assert.equal("c_shared", snapshot.get("shared.deep"), "Should preserve child override after delete"); + Assert.equal(5, snapshot.size, "Snapshot size should remain unchanged"); + + // Test that child now sees different inheritance chain + Assert.equal(undefined, child.get("shared.deep"), "Child should still won't see grandparent value"); + Assert.equal(undefined, child.get("gp.key"), "Child should not see deleted grandparent key"); + Assert.equal(undefined, child.get("ggp.key"), "Child should not see cleared great-grandparent key"); + + // Verify source tracking in snapshot after complex changes + Assert.ok(snapshot.has("shared.deep", eAttributeFilter.Local), "shared.deep should be local in snapshot"); + Assert.ok(snapshot.has("ggp.key", eAttributeFilter.Local), "ggp.key should be copied locally in snapshot"); + Assert.ok(snapshot.has("gp.key", eAttributeFilter.Local), "gp.key should be copied locally in snapshot"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - basic functionality without snapshot", + test: () => { + const otelCfg: IOTelConfig = {}; + const parent = createAttributeContainer(otelCfg, "parent-container"); + + // Add some attributes to parent + parent.set("parent.key", "parent-value"); + parent.set("shared.key", "parent-shared"); + + // Create child without snapshot + const child = parent.child("test-child", false); + + // Verify child container basics + Assert.ok(isAttributeContainer(child), "Child should be valid attribute container"); + Assert.notEqual(parent.id, child.id, "Child should have different ID"); + Assert.ok(child.id.includes("<=["), "Child ID should indicate it's a child"); + Assert.ok(child.id.includes(parent.id), "Child ID should reference parent ID"); + Assert.ok(child.id.includes("test-child"), "Child ID should include provided name"); + + // Verify inheritance + Assert.equal("parent-value", child.get("parent.key"), "Child should inherit parent values"); + Assert.equal("parent-shared", child.get("shared.key"), "Child should inherit shared values"); + Assert.equal(2, child.size, "Child size should include inherited attributes"); + + // Verify child can override parent values + Assert.ok(child.set("shared.key", "child-shared"), "Child should be able to override parent"); + Assert.equal("child-shared", child.get("shared.key"), "Child should return overridden value"); + Assert.equal("parent-shared", parent.get("shared.key"), "Parent should retain original value"); + + // Verify child can add new attributes + Assert.ok(child.set("child.key", "child-value"), "Child should be able to add new attributes"); + Assert.equal("child-value", child.get("child.key"), "Child should return new attribute"); + Assert.equal(undefined, parent.get("child.key"), "Parent should not see child-only attributes"); + + // Verify size calculations + Assert.equal(3, child.size, "Child size should include parent + child attributes"); + Assert.equal(2, parent.size, "Parent size should remain unchanged"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - basic functionality with snapshot", + test: () => { + const otelCfg: IOTelConfig = {}; + const parent = createAttributeContainer(otelCfg, "parent-container"); + + // Add some attributes to parent + parent.set("parent.key", "parent-value"); + parent.set("shared.key", "parent-shared"); + + // Create child with snapshot + const child = parent.child("test-child", true); + + // Verify child container basics + Assert.ok(isAttributeContainer(child), "Child snapshot should be valid attribute container"); + Assert.notEqual(parent.id, child.id, "Child snapshot should have different ID"); + Assert.ok(child.id.includes("<-@["), "Child ID should indicate it's a snapshot child"); + Assert.ok(child.id.includes(parent.id), "Child ID should reference parent ID"); + Assert.ok(child.id.includes("test-child"), "Child ID should include provided name"); + + // Verify initial inheritance (snapshot captures current state) + Assert.equal("parent-value", child.get("parent.key"), "Child snapshot should have parent values"); + Assert.equal("parent-shared", child.get("shared.key"), "Child snapshot should have shared values"); + Assert.equal(2, child.size, "Child snapshot size should include captured attributes"); + + // Verify child can modify without affecting parent + Assert.ok(child.set("shared.key", "child-shared"), "Child snapshot should allow modifications"); + Assert.equal("child-shared", child.get("shared.key"), "Child snapshot should return modified value"); + Assert.equal("parent-shared", parent.get("shared.key"), "Parent should retain original value"); + + // Verify parent changes don't affect snapshot child + parent.set("parent.key", "parent-modified"); + parent.set("new.key", "new-value"); + Assert.equal("parent-value", child.get("parent.key"), "Child snapshot should retain original parent value"); + Assert.equal(undefined, child.get("new.key"), "Child snapshot should not see new parent attributes"); + Assert.equal(2, child.size, "Child snapshot size should remain stable"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - inheritance behavior differences", + test: () => { + const otelCfg: IOTelConfig = {}; + const parent = createAttributeContainer(otelCfg, "parent-container"); + + // Setup initial parent state + parent.set("base.key", "base-value"); + parent.set("dynamic.key", "dynamic-initial"); + + // Create both types of children + const regularChild = parent.child("regular", false); + const snapshotChild = parent.child("snapshot", true); + + // Both should see initial state + Assert.equal("base-value", regularChild.get("base.key"), "Regular child should see initial base value"); + Assert.equal("base-value", snapshotChild.get("base.key"), "Snapshot child should see initial base value"); + Assert.equal("dynamic-initial", regularChild.get("dynamic.key"), "Regular child should see initial dynamic value"); + Assert.equal("dynamic-initial", snapshotChild.get("dynamic.key"), "Snapshot child should see initial dynamic value"); + + // Modify parent after children created + parent.set("dynamic.key", "dynamic-modified"); + parent.set("new.key", "new-value"); + + // Regular child should see changes, snapshot should not + Assert.equal("dynamic-modified", regularChild.get("dynamic.key"), "Regular child should see parent modifications"); + Assert.equal("new-value", regularChild.get("new.key"), "Regular child should see new parent attributes"); + Assert.equal(3, regularChild.size, "Regular child size should include new parent attributes"); + + Assert.equal("dynamic-initial", snapshotChild.get("dynamic.key"), "Snapshot child should retain original value"); + Assert.equal(undefined, snapshotChild.get("new.key"), "Snapshot child should not see new parent attributes"); + Assert.equal(2, snapshotChild.size, "Snapshot child size should remain stable"); + + // Delete from parent + Assert.ok(parent.del("base.key"), "Should be able to delete from parent"); + Assert.equal(undefined, regularChild.get("base.key"), "Regular child should see parent deletions"); + Assert.equal("base-value", snapshotChild.get("base.key"), "Snapshot child should retain deleted values"); + Assert.equal(2, regularChild.size, "Regular child size should reflect parent deletions"); + Assert.equal(2, snapshotChild.size, "Snapshot child size should remain stable"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - nested children behavior", + test: () => { + const otelCfg: IOTelConfig = {}; + const grandparent = createAttributeContainer(otelCfg, "grandparent"); + + // Setup inheritance chain + grandparent.set("gp.key", "gp-value"); + grandparent.set("shared.key", "gp-shared"); + + // Create regular child from grandparent + const parent = grandparent.child("parent", false); + parent.set("parent.key", "parent-value"); + parent.set("shared.key", "parent-shared"); // Override grandparent + + // Create both types of grandchildren + const regularGrandchild = parent.child("regular-gc", false); + const snapshotGrandchild = parent.child("snapshot-gc", true); + + // Both grandchildren should see the inheritance chain + Assert.equal("gp-value", regularGrandchild.get("gp.key"), "Regular grandchild should see grandparent values"); + Assert.equal("parent-value", regularGrandchild.get("parent.key"), "Regular grandchild should see parent values"); + Assert.equal("parent-shared", regularGrandchild.get("shared.key"), "Regular grandchild should see parent override"); + + Assert.equal("gp-value", snapshotGrandchild.get("gp.key"), "Snapshot grandchild should see grandparent values"); + Assert.equal("parent-value", snapshotGrandchild.get("parent.key"), "Snapshot grandchild should see parent values"); + Assert.equal("parent-shared", snapshotGrandchild.get("shared.key"), "Snapshot grandchild should see parent override"); + + // Modify grandparent after grandchildren created + grandparent.set("gp.key", "gp-modified"); + grandparent.set("gp.new", "gp-new-value"); + + // Regular grandchild should see changes, snapshot should not + Assert.equal("gp-modified", regularGrandchild.get("gp.key"), "Regular grandchild should see grandparent changes"); + Assert.equal("gp-new-value", regularGrandchild.get("gp.new"), "Regular grandchild should see new grandparent attributes"); + + Assert.equal("gp-value", snapshotGrandchild.get("gp.key"), "Snapshot grandchild should retain original grandparent value"); + Assert.equal(undefined, snapshotGrandchild.get("gp.new"), "Snapshot grandchild should not see new grandparent attributes"); + + // Modify parent after grandchildren created + parent.set("parent.key", "parent-modified"); + + // Regular grandchild should see parent changes, snapshot should not + Assert.equal("parent-modified", regularGrandchild.get("parent.key"), "Regular grandchild should see parent changes"); + Assert.equal("parent-value", snapshotGrandchild.get("parent.key"), "Snapshot grandchild should retain original parent value"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - child creation from children", + test: () => { + const otelCfg: IOTelConfig = {}; + const root = createAttributeContainer(otelCfg, "root"); + + root.set("root.key", "root-value"); + + // Create first level children + const regularChild = root.child("regular", false); + const snapshotChild = root.child("snapshot", true); + + regularChild.set("regular.key", "regular-value"); + snapshotChild.set("snapshot.key", "snapshot-value"); + + // Create second level children from each type + const regularFromRegular = regularChild.child("reg-from-reg", false); + const snapshotFromRegular = regularChild.child("snap-from-reg", true); + const regularFromSnapshot = snapshotChild.child("reg-from-snap", false); + const snapshotFromSnapshot = snapshotChild.child("snap-from-snap", true); + + // All should see root value + Assert.equal("root-value", regularFromRegular.get("root.key"), "Regular from regular should see root"); + Assert.equal("root-value", snapshotFromRegular.get("root.key"), "Snapshot from regular should see root"); + Assert.equal("root-value", regularFromSnapshot.get("root.key"), "Regular from snapshot should see root"); + Assert.equal("root-value", snapshotFromSnapshot.get("root.key"), "Snapshot from snapshot should see root"); + + // Only children of regular parent should see regular parent's values + Assert.equal("regular-value", regularFromRegular.get("regular.key"), "Regular from regular should see regular parent"); + Assert.equal("regular-value", snapshotFromRegular.get("regular.key"), "Snapshot from regular should see regular parent"); + Assert.equal(undefined, regularFromSnapshot.get("regular.key"), "Regular from snapshot should not see regular parent"); + Assert.equal(undefined, snapshotFromSnapshot.get("regular.key"), "Snapshot from snapshot should not see regular parent"); + + // Only children of snapshot parent should see snapshot parent's values + Assert.equal(undefined, regularFromRegular.get("snapshot.key"), "Regular from regular should not see snapshot parent"); + Assert.equal(undefined, snapshotFromRegular.get("snapshot.key"), "Snapshot from regular should not see snapshot parent"); + Assert.equal("snapshot-value", regularFromSnapshot.get("snapshot.key"), "Regular from snapshot should see snapshot parent"); + Assert.equal("snapshot-value", snapshotFromSnapshot.get("snapshot.key"), "Snapshot from snapshot should see snapshot parent"); + + // Modify root after all children created + root.set("root.key", "root-modified"); + + // Regular children should see changes, snapshot children should not + Assert.equal("root-modified", regularFromRegular.get("root.key"), "Regular from regular should see root changes"); + Assert.equal("root-value", snapshotFromRegular.get("root.key"), "Snapshot from regular should retain original root"); + Assert.equal("root-value", regularFromSnapshot.get("root.key"), "Regular from snapshot should see root changes"); + Assert.equal("root-value", snapshotFromSnapshot.get("root.key"), "Snapshot from snapshot should retain original root"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - clear and delete operations", + test: () => { + const otelCfg: IOTelConfig = {}; + const parent = createAttributeContainer(otelCfg, "parent"); + + parent.set("parent.key1", "value1"); + parent.set("parent.key2", "value2"); + parent.set("shared.key", "parent-shared"); + + const regularChild = parent.child("regular", false); + const snapshotChild = parent.child("snapshot", true); + + // Both children should see all parent attributes + Assert.equal(3, regularChild.size, "Regular child should see all parent attributes"); + Assert.equal(3, snapshotChild.size, "Snapshot child should see all parent attributes"); + + // Add child-specific attributes + regularChild.set("regular.key", "regular-value"); + snapshotChild.set("snapshot.key", "snapshot-value"); + + Assert.equal(4, regularChild.size, "Regular child size should include child attribute"); + Assert.equal(4, snapshotChild.size, "Snapshot child size should include child attribute"); + + // Clear parent + parent.clear(); + + // After parent.clear(), children still maintain parent connection and see the empty parent + Assert.equal(1, regularChild.size, "Regular child should only have own attributes after parent clear"); + Assert.equal("regular-value", regularChild.get("regular.key"), "Regular child should retain own attributes"); + Assert.equal(undefined, regularChild.get("parent.key1"), "Regular child should not see cleared parent attributes"); + + // Snapshot child preserves attributes at time of snapshot creation via lazy copying + Assert.equal(4, snapshotChild.size, "Snapshot child should retain all attributes after parent clear"); + Assert.equal("value1", snapshotChild.get("parent.key1"), "Snapshot child should retain parent attributes"); + Assert.equal("snapshot-value", snapshotChild.get("snapshot.key"), "Snapshot child should retain own attributes"); + + // Clear children - this breaks THEIR parent connections + regularChild.clear(); + snapshotChild.clear(); + + // Both should be empty and lose parent connections + Assert.equal(0, regularChild.size, "Regular child should be empty after clear"); + Assert.equal(0, snapshotChild.size, "Snapshot child should be empty after clear"); + + // Re-add to parent - children should NOT see it since THEIR clear() removed parent connections + parent.set("parent.new", "new-value"); + + // Regular child should NOT see new parent attribute after its own clear() removed parent connection + Assert.equal(0, regularChild.size, "Regular child should not see new parent attribute after its own clear"); + Assert.equal(undefined, regularChild.get("parent.new"), "Regular child should not get new parent value after its own clear"); + + Assert.equal(0, snapshotChild.size, "Snapshot child should remain empty"); + Assert.equal(undefined, snapshotChild.get("parent.new"), "Snapshot child should not see new parent attributes"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - listener propagation", + test: () => { + const otelCfg: IOTelConfig = {}; + const parent = createAttributeContainer(otelCfg, "parent"); + + parent.set("parent.key", "parent-value"); + + const regularChild = parent.child("regular", false); + const snapshotChild = parent.child("snapshot", true); + + let regularChildChanges: IAttributeChangeInfo[] = []; + let snapshotChildChanges: IAttributeChangeInfo[] = []; + + // Add listeners to children + const regularHook = regularChild.listen((change) => regularChildChanges.push(change)); + const snapshotHook = snapshotChild.listen((change) => snapshotChildChanges.push(change)); + + // Modify parent + parent.set("parent.key", "parent-modified"); + parent.set("parent.new", "parent-new"); + + // Both regular and snapshot children should receive change notifications + // Regular children propagate parent changes directly + // Snapshot children listen to maintain snapshot state + Assert.equal(2, regularChildChanges.length, "Regular child should receive parent change notifications"); + // Snapshots get an extra notification if a parent changes a value and the local snapshot doesn't have the value or + // it's different from the value being changed. + Assert.equal(4, snapshotChildChanges.length, "Snapshot child should receive parent change notifications to maintain snapshot state"); + + // Verify change details for regular child + Assert.equal("parent.key", regularChildChanges[0].k, "First change should be parent.key"); + Assert.equal(eAttributeChangeOp.Set, regularChildChanges[0].op, "First change should be Set operation"); + Assert.equal("parent.new", regularChildChanges[1].k, "Second change should be parent.new"); + Assert.equal(eAttributeChangeOp.Add, regularChildChanges[1].op, "Second change should be Add operation"); + + // Verify change details for snapshot child (receives notifications to manage snapshot state) + Assert.equal("parent.key", snapshotChildChanges[0].k, "Snapshot child should receive parent.key change"); + Assert.equal(eAttributeChangeOp.Set, snapshotChildChanges[0].op, "Snapshot child should see Set operation"); + Assert.equal("parent.key", snapshotChildChanges[1].k, "Snapshot child should receive 2nd notification for parent.key change"); + Assert.equal(eAttributeChangeOp.Set, snapshotChildChanges[1].op, "Snapshot child should see 2nd Set operation"); + // It won't receive any notifications for "parent.new" as it didn't exist prior to the snapshot + + // Modify children directly + regularChild.set("regular.key", "regular-value"); + snapshotChild.set("snapshot.key", "snapshot-value"); + + // Each child should receive its own change notification + Assert.equal(3, regularChildChanges.length, "Regular child should receive own change"); + Assert.equal(5, snapshotChildChanges.length, "Snapshot child should receive own change"); + + // Clean up listeners + regularHook.rm(); + snapshotHook.rm(); + + // No more changes should be received after listeners removed + parent.set("parent.final", "final-value"); + Assert.equal(3, regularChildChanges.length, "Regular child should not receive changes after listener removed"); + Assert.equal(5, snapshotChildChanges.length, "Snapshot child should not receive changes after listener removed"); + } + }); + + this.testCase({ + name: "AttributeContainer: Child function - clear behavior differences (parent vs child clear)", + test: () => { + const otelCfg: IOTelConfig = {}; + const parent = createAttributeContainer(otelCfg, "parent"); + + parent.set("parent.key", "parent-value"); + + // Test 1: Parent clear - children maintain connection + const regularChild1 = parent.child("regular1", false); + const snapshotChild1 = parent.child("snapshot1", true); + + // Both should see parent initially + Assert.equal(1, regularChild1.size, "Regular child should see parent attribute"); + Assert.equal(1, snapshotChild1.size, "Snapshot child should see parent attribute"); + + // Parent clears - children still have connection but see empty parent + parent.clear(); + Assert.equal(0, regularChild1.size, "Regular child should see empty parent after parent clear"); + Assert.equal(1, snapshotChild1.size, "Snapshot child should preserve attributes after parent clear"); + + // Add new attribute to parent - regular child should see it (connection maintained) + parent.set("new.key", "new-value"); + Assert.equal(1, regularChild1.size, "Regular child should see new parent attribute (connection maintained)"); + Assert.equal("new-value", regularChild1.get("new.key"), "Regular child should get new parent value"); + Assert.equal(1, snapshotChild1.size, "Snapshot child should not see new parent attribute"); + + // Test 2: Child clear - child loses parent connection + const regularChild2 = parent.child("regular2", false); + const snapshotChild2 = parent.child("snapshot2", true); + + // Both should see parent + Assert.equal(1, regularChild2.size, "Regular child2 should see parent attribute"); + Assert.equal(1, snapshotChild2.size, "Snapshot child2 should see parent attribute"); + + // Children clear themselves - they lose parent connection + regularChild2.clear(); + snapshotChild2.clear(); + + Assert.equal(0, regularChild2.size, "Regular child2 should be empty after its own clear"); + Assert.equal(0, snapshotChild2.size, "Snapshot child2 should be empty after its own clear"); + + // Add another attribute to parent - cleared children should NOT see it (connection lost) + parent.set("another.key", "another-value"); + Assert.equal(0, regularChild2.size, "Regular child2 should not see parent additions after its own clear"); + Assert.equal(0, snapshotChild2.size, "Snapshot child2 should not see parent additions after its own clear"); + + // But the first regular child should still see it (it didn't clear itself) + Assert.equal(2, regularChild1.size, "Regular child1 should see all parent attributes (it didn't clear itself)"); + Assert.equal("another-value", regularChild1.get("another.key"), "Regular child1 should get the latest parent value"); + } + }); + } +} + + diff --git a/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/commonUtils.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/commonUtils.Tests.ts new file mode 100644 index 000000000..14094795b --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/commonUtils.Tests.ts @@ -0,0 +1,1031 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { getGlobal } from "@nevware21/ts-utils"; +import { + handleAttribError, handleSpanError, handleDebug, handleWarn, handleError, handleNotImplemented +} from "../../../../src/OpenTelemetry/helpers/handleErrors"; +import { getUrl, getHttpUrl } from "../../../../src/OpenTelemetry/helpers/common"; +import { createAttributeContainer } from "../../../../src/OpenTelemetry/attribute/attributeContainer"; +import { IOTelErrorHandlers } from "../../../../src/OpenTelemetry/interfaces/config/IOTelErrorHandlers"; +import { IOTelConfig } from "../../../../src/OpenTelemetry/interfaces/config/IOTelConfig"; + +export class CommonUtilsTests extends AITestClass { + + public testInitialize() { + super.testInitialize(); + // Reset console mocks before each test + this._resetConsoleMocks(); + } + + public testCleanup() { + super.testCleanup(); + // Reset console mocks after each test + this._resetConsoleMocks(); + } + + public registerTests() { + this.testCase({ + name: "handleAttribError: should call custom attribError handler when provided", + test: () => { + // Arrange + let calledMessage = ""; + let calledKey = ""; + let calledValue: any = null; + const handlers: IOTelErrorHandlers = { + attribError: (message: string, key: string, value: any) => { + calledMessage = message; + calledKey = key; + calledValue = value; + } + }; + const testMessage = "Test error message"; + const testKey = "testKey"; + const testValue = { test: "value" }; + + // Act + handleAttribError(handlers, testMessage, testKey, testValue); + + // Assert + Assert.ok(calledMessage === testMessage, "Message should match"); + Assert.ok(calledKey === testKey, "Key should match"); + Assert.ok(calledValue === testValue, "Value should match"); + } + }); + + this.testCase({ + name: "handleAttribError: should call handleWarn when no custom attribError handler provided", + test: () => { + // Arrange + let warnCalled = false; + let warnMessage = ""; + const handlers: IOTelErrorHandlers = { + warn: (message: string) => { + warnCalled = true; + warnMessage = message; + } + }; + const testMessage = "Test error"; + const testKey = "testKey"; + const testValue = "testValue"; + + // Act + handleAttribError(handlers, testMessage, testKey, testValue); + + // Assert + Assert.ok(warnCalled, "Warn should be called"); + Assert.ok(warnMessage.includes(testMessage), "Warn message should contain original message"); + Assert.ok(warnMessage.includes(testKey), "Warn message should contain key"); + Assert.ok(warnMessage.includes(testValue), "Warn message should contain value"); + } + }); + + this.testCase({ + name: "handleSpanError: should call custom spanError handler when provided", + test: () => { + // Arrange + let calledMessage = ""; + let calledSpanName = ""; + const handlers: IOTelErrorHandlers = { + spanError: (message: string, spanName: string) => { + calledMessage = message; + calledSpanName = spanName; + } + }; + const testMessage = "Span error occurred"; + const testSpanName = "testSpan"; + + // Act + handleSpanError(handlers, testMessage, testSpanName); + + // Assert + Assert.ok(calledMessage === testMessage, "Message should match"); + Assert.ok(calledSpanName === testSpanName, "Span name should match"); + } + }); + + this.testCase({ + name: "handleSpanError: should call handleWarn when no custom spanError handler provided", + test: () => { + // Arrange + let warnCalled = false; + let warnMessage = ""; + const handlers: IOTelErrorHandlers = { + warn: (message: string) => { + warnCalled = true; + warnMessage = message; + } + }; + const testMessage = "Span error"; + const testSpanName = "testSpan"; + + // Act + handleSpanError(handlers, testMessage, testSpanName); + + // Assert + Assert.ok(warnCalled, "Warn should be called"); + Assert.ok(warnMessage.includes(testMessage), "Warn message should contain original message"); + Assert.ok(warnMessage.includes(testSpanName), "Warn message should contain span name"); + } + }); + + this.testCase({ + name: "handleDebug: should call custom debug handler when provided", + test: () => { + // Arrange + let debugCalled = false; + let debugMessage = ""; + const handlers: IOTelErrorHandlers = { + debug: (message: string) => { + debugCalled = true; + debugMessage = message; + } + }; + const testMessage = "Debug message"; + + // Act + handleDebug(handlers, testMessage); + + // Assert + Assert.ok(debugCalled, "Debug should be called"); + Assert.ok(debugMessage === testMessage, "Debug message should match"); + } + }); + + this.testCase({ + name: "handleDebug: should use console.log when no custom debug handler provided", + test: () => { + // Arrange + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Debug via console"; + let consoleCalled = false; + let consoleMessage = ""; + + // Mock console.log + const originalConsole = console; + const globalObj = (typeof window !== "undefined") ? window : (global || {}); + (globalObj as any).console = { + log: (message: string) => { + consoleCalled = true; + consoleMessage = message; + } + }; + + try { + // Act + handleDebug(handlers, testMessage); + + // Assert + Assert.ok(consoleCalled, "Console.log should be called"); + Assert.ok(consoleMessage === testMessage, "Console message should match"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.testCase({ + name: "handleWarn: should call custom warn handler when provided", + test: () => { + // Arrange + let warnCalled = false; + let warnMessage = ""; + const handlers: IOTelErrorHandlers = { + warn: (message: string) => { + warnCalled = true; + warnMessage = message; + } + }; + const testMessage = "Warning message"; + + // Act + handleWarn(handlers, testMessage); + + // Assert + Assert.ok(warnCalled, "Warn should be called"); + Assert.ok(warnMessage === testMessage, "Warn message should match"); + } + }); + + this.testCase({ + name: "handleWarn: should use console.warn when no custom warn handler provided", + test: () => { + // Arrange + const globalObj = getGlobal(); + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Warning via console"; + let consoleCalled = false; + let consoleMessage = ""; + + // Mock console.warn + const originalConsole = console; + (globalObj as any).console = { + warn: (message: string) => { + consoleCalled = true; + consoleMessage = message; + } + }; + + try { + // Act + handleWarn(handlers, testMessage); + + // Assert + Assert.ok(consoleCalled, "Console.warn should be called"); + Assert.ok(consoleMessage === testMessage, "Console message should match"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.testCase({ + name: "handleWarn: should fallback to console.log when console.warn not available", + test: () => { + // Arrange + const globalObj = getGlobal(); + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Warning fallback to log"; + let consoleCalled = false; + let consoleMessage = ""; + + // Mock console without warn + const originalConsole = console; + (globalObj as any).console = { + log: (message: string) => { + consoleCalled = true; + consoleMessage = message; + } + }; + + try { + // Act + handleWarn(handlers, testMessage); + + // Assert + Assert.ok(consoleCalled, "Console.log should be called as fallback"); + Assert.ok(consoleMessage === testMessage, "Console message should match"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.testCase({ + name: "handleError: should call custom error handler when provided", + test: () => { + // Arrange + let errorCalled = false; + let errorMessage = ""; + const handlers: IOTelErrorHandlers = { + error: (message: string) => { + errorCalled = true; + errorMessage = message; + } + }; + const testMessage = "Error message"; + + // Act + handleError(handlers, testMessage); + + // Assert + Assert.ok(errorCalled, "Error should be called"); + Assert.ok(errorMessage === testMessage, "Error message should match"); + } + }); + + this.testCase({ + name: "handleError: should fallback to warn handler when no custom error handler provided", + test: () => { + // Arrange + let warnCalled = false; + let warnMessage = ""; + const handlers: IOTelErrorHandlers = { + warn: (message: string) => { + warnCalled = true; + warnMessage = message; + } + }; + const testMessage = "Error fallback to warn"; + + // Act + handleError(handlers, testMessage); + + // Assert + Assert.ok(warnCalled, "Warn should be called as fallback"); + Assert.ok(warnMessage === testMessage, "Warn message should match"); + } + }); + + this.testCase({ + name: "handleError: should use console.error when no custom handlers provided", + test: () => { + // Arrange + const globalObj = getGlobal(); + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Error via console"; + let consoleCalled = false; + let consoleMessage = ""; + + // Mock console.error + const originalConsole = console; + (globalObj as any).console = { + error: (message: string) => { + consoleCalled = true; + consoleMessage = message; + } + }; + + try { + // Act + handleError(handlers, testMessage); + + // Assert + Assert.ok(consoleCalled, "Console.error should be called"); + Assert.ok(consoleMessage === testMessage, "Console message should match"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.testCase({ + name: "handleError: should fallback through console methods when preferred not available", + test: () => { + // Arrange + const globalObj = getGlobal(); + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Error fallback chain"; + let consoleCalled = false; + let consoleMessage = ""; + + // Mock console with only log available + const originalConsole = console; + (globalObj as any).console = { + log: (message: string) => { + consoleCalled = true; + consoleMessage = message; + } + }; + + try { + // Act + handleError(handlers, testMessage); + + // Assert + Assert.ok(consoleCalled, "Console.log should be called as final fallback"); + Assert.ok(consoleMessage === testMessage, "Console message should match"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.testCase({ + name: "handleNotImplemented: should call custom notImplemented handler when provided", + test: () => { + // Arrange + let notImplementedCalled = false; + let notImplementedMessage = ""; + const handlers: IOTelErrorHandlers = { + notImplemented: (message: string) => { + notImplementedCalled = true; + notImplementedMessage = message; + } + }; + const testMessage = "Not implemented feature"; + + // Act + handleNotImplemented(handlers, testMessage); + + // Assert + Assert.ok(notImplementedCalled, "NotImplemented should be called"); + Assert.ok(notImplementedMessage === testMessage, "NotImplemented message should match"); + } + }); + + this.testCase({ + name: "handleNotImplemented: should use console.error when no custom handler provided", + test: () => { + // Arrange + const globalObj = getGlobal(); + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Not implemented via console"; + let consoleCalled = false; + let consoleMessage = ""; + + // Mock console.error + const originalConsole = console; + (globalObj as any).console = { + error: (message: string) => { + consoleCalled = true; + consoleMessage = message; + } + }; + + try { + // Act + handleNotImplemented(handlers, testMessage); + + // Assert + Assert.ok(consoleCalled, "Console.error should be called"); + Assert.ok(consoleMessage === testMessage, "Console message should match"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.testCase({ + name: "handleNotImplemented: should fallback to console.log when console.error not available", + test: () => { + // Arrange + const globalObj = getGlobal(); + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Not implemented fallback"; + let consoleCalled = false; + let consoleMessage = ""; + + // Mock console with only log available + const originalConsole = console; + (globalObj as any).console = { + log: (message: string) => { + consoleCalled = true; + consoleMessage = message; + } + }; + + try { + // Act + handleNotImplemented(handlers, testMessage); + + // Assert + Assert.ok(consoleCalled, "Console.log should be called as fallback"); + Assert.ok(consoleMessage === testMessage, "Console message should match"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.testCase({ + name: "Error handlers should handle undefined console gracefully", + test: () => { + // Arrange + const globalObj = getGlobal(); + const handlers: IOTelErrorHandlers = {}; + const testMessage = "Test with no console"; + const originalConsole = console; + + try { + // Remove console + (globalObj as any).console = undefined; + + // Act & Assert - should not throw + handleDebug(handlers, testMessage); + handleWarn(handlers, testMessage); + handleError(handlers, testMessage); + handleNotImplemented(handlers, testMessage); + + // If we get here, no exceptions were thrown + Assert.ok(true, "All handlers should complete without throwing when console is undefined"); + } finally { + // Restore console + (globalObj as any).console = originalConsole; + } + } + }); + + this.addGetUrlTests(); + this.addGetHttpUrlTests(); + } + + private addGetHttpUrlTests(): void { + this.testCase({ + name: "getHttpUrl: should return undefined when container is null", + test: () => { + // Act + const result = getHttpUrl(null as any); + + // Assert + Assert.equal(result, undefined, "Should return undefined for null container"); + } + }); + + this.testCase({ + name: "getHttpUrl: should return undefined when container is undefined", + test: () => { + // Act + const result = getHttpUrl(undefined as any); + + // Assert + Assert.equal(result, undefined, "Should return undefined for undefined container"); + } + }); + + this.testCase({ + name: "getHttpUrl: should return value from url.full (stable semantic convention)", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("url.full", "https://example.com/api/users?id=123"); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, "https://example.com/api/users?id=123", "Should return value from url.full"); + } + }); + + this.testCase({ + name: "getHttpUrl: should return value from http.url (legacy semantic convention)", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.url", "https://legacy.example.com/endpoint"); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, "https://legacy.example.com/endpoint", "Should return value from http.url"); + } + }); + + this.testCase({ + name: "getHttpUrl: should prefer url.full over http.url when both present", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("url.full", "https://stable.example.com/path"); + container.set("http.url", "https://legacy.example.com/path"); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, "https://stable.example.com/path", "Should prefer url.full over http.url"); + } + }); + + this.testCase({ + name: "getHttpUrl: should return undefined when neither attribute is present", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("url.scheme", "https"); + container.set("server.address", "example.com"); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, undefined, "Should return undefined when neither url.full nor http.url is present"); + } + }); + + this.testCase({ + name: "getHttpUrl: should handle empty string values", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("url.full", ""); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, undefined, "Should return empty string when url.full is empty"); + } + }); + + this.testCase({ + name: "getHttpUrl: should handle numeric values", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("url.full", 12345); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, 12345, "Should return numeric value as-is"); + } + }); + + this.testCase({ + name: "getHttpUrl: should handle boolean values", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("url.full", true); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, true, "Should return boolean value as-is"); + } + }); + + this.testCase({ + name: "getHttpUrl: should handle URLs with special characters", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + const urlWithSpecialChars = "https://example.com/api/search?q=hello%20world&filter=%7B%22type%22%3A%22test%22%7D"; + container.set("url.full", urlWithSpecialChars); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, urlWithSpecialChars, "Should handle URLs with encoded special characters"); + } + }); + + this.testCase({ + name: "getHttpUrl: should handle relative URLs", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.url", "/api/users"); + + // Act + const result = getHttpUrl(container); + + // Assert + Assert.equal(result, "/api/users", "Should handle relative URLs"); + } + }); + } + + private addGetUrlTests(): void { + this.testCase({ + name: "getUrl: should return empty string when container is null", + test: () => { + // Act + const result = getUrl(null as any); + + // Assert + Assert.equal(result, "", "Should return empty string for null container"); + } + }); + + this.testCase({ + name: "getUrl: should return empty string when container is undefined", + test: () => { + // Act + const result = getUrl(undefined as any); + + // Assert + Assert.equal(result, "", "Should return empty string for undefined container"); + } + }); + + this.testCase({ + name: "getUrl: should return empty string when no httpMethod is present", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("url.full", "https://example.com/path"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "", "Should return empty string when httpMethod is missing"); + } + }); + + this.testCase({ + name: "getUrl: should return url from url.full (stable semantic convention)", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.full", "https://example.com/api/users"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://example.com/api/users", "Should return url from url.full"); + } + }); + + this.testCase({ + name: "getUrl: should return url from http.url (legacy semantic convention)", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.method", "POST"); + container.set("http.url", "https://api.example.com/data"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://api.example.com/data", "Should return url from http.url"); + } + }); + + this.testCase({ + name: "getUrl: should prefer url.full over http.url when both present", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.full", "https://stable.example.com/path"); + container.set("http.url", "https://legacy.example.com/path"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://stable.example.com/path", "Should prefer url.full over http.url"); + } + }); + + this.testCase({ + name: "getUrl: should construct url from httpScheme, httpHost, and httpTarget", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.scheme", "https"); + container.set("server.address", "example.com"); + container.set("url.path", "/api/users"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://example.com/api/users", "Should construct url from scheme, host, and path"); + } + }); + + this.testCase({ + name: "getUrl: should construct url from legacy http attributes", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.method", "POST"); + container.set("http.scheme", "http"); + container.set("http.host", "localhost:8080"); + container.set("http.target", "/api/data?id=123"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "http://localhost:8080/api/data?id=123", "Should construct url from legacy http attributes"); + } + }); + + this.testCase({ + name: "getUrl: should use url.query when url.path is not present", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.scheme", "https"); + container.set("server.address", "example.com"); + container.set("url.query", "?q=search"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://example.com?q=search", "Should use url.query when url.path not present"); + } + }); + + this.testCase({ + name: "getUrl: should construct url with netPeerName and netPeerPort when httpHost not present", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.method", "GET"); + container.set("http.scheme", "http"); + container.set("net.peer.name", "api.service.local"); + container.set("net.peer.port", 8080); + container.set("http.target", "/health"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "http://api.service.local:8080/health", "Should construct url with netPeerName and port"); + } + }); + + this.testCase({ + name: "getUrl: should construct url with client.address (stable) for netPeerName", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.scheme", "https"); + container.set("client.address", "service.example.com"); + container.set("client.port", 443); + container.set("url.path", "/api"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://service.example.com:443/api", "Should use client.address for peer name"); + } + }); + + this.testCase({ + name: "getUrl: should construct url with netPeerIp when netPeerName not present", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.method", "GET"); + container.set("http.scheme", "http"); + container.set("net.peer.ip", "192.168.1.100"); + container.set("net.peer.port", 3000); + container.set("http.target", "/endpoint"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "http://192.168.1.100:3000/endpoint", "Should construct url with IP address and port"); + } + }); + + this.testCase({ + name: "getUrl: should use network.peer.address (stable) for peer IP", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "POST"); + container.set("url.scheme", "https"); + container.set("network.peer.address", "10.0.0.5"); + container.set("server.port", 8443); + container.set("url.path", "/data"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://10.0.0.5:8443/data", "Should use network.peer.address for IP"); + } + }); + + this.testCase({ + name: "getUrl: should return empty string when scheme and target present but no host/peer info", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.scheme", "https"); + container.set("url.path", "/api/users"); + // No host, no peer name, no peer IP + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "", "Should return empty string when no host information available"); + } + }); + + this.testCase({ + name: "getUrl: should return empty string when scheme present but target missing", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.scheme", "https"); + container.set("server.address", "example.com"); + // No target/path/query + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "", "Should return empty string when target is missing"); + } + }); + + this.testCase({ + name: "getUrl: should handle IPv6 addresses", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.method", "GET"); + container.set("http.scheme", "http"); + container.set("net.peer.ip", "::1"); + container.set("net.peer.port", 8080); + container.set("http.target", "/api"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "http://::1:8080/api", "Should handle IPv6 addresses"); + } + }); + + this.testCase({ + name: "getUrl: should prefer server.port over other port attributes", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.scheme", "https"); + container.set("client.address", "example.com"); + container.set("server.port", 9000); + container.set("client.port", 8000); + container.set("net.peer.port", 7000); + container.set("url.path", "/api"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://example.com:8000/api", "Should prefer server.port"); + } + }); + + this.testCase({ + name: "getUrl: should handle Unix socket paths in network.peer.address", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.request.method", "GET"); + container.set("url.scheme", "http"); + container.set("network.peer.address", "/tmp/my.sock"); + container.set("server.port", 80); + container.set("url.path", "/status"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "http:///tmp/my.sock:80/status", "Should handle Unix socket paths"); + } + }); + + this.testCase({ + name: "getUrl: should construct url with path containing query parameters", + test: () => { + // Arrange + const otelCfg: IOTelConfig = {}; + const container = createAttributeContainer(otelCfg, "test"); + container.set("http.method", "GET"); + container.set("http.scheme", "https"); + container.set("http.host", "api.example.com"); + container.set("http.target", "/search?q=test&page=1"); + + // Act + const result = getUrl(container); + + // Assert + Assert.equal(result, "https://api.example.com/search?q=test&page=1", "Should handle target with query parameters"); + } + }); + } + + private _resetConsoleMocks() { + // Helper to reset any console mocks - implementation depends on your mocking strategy + // This is a placeholder that might need adjustment based on your test framework + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/errors.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/errors.Tests.ts new file mode 100644 index 000000000..f0c708dfb --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/errors.Tests.ts @@ -0,0 +1,680 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { isFunction, isString, dumpObj } from "@nevware21/ts-utils"; +import { + OpenTelemetryError, + OpenTelemetryErrorConstructor, + getOpenTelemetryError, + throwOTelError +} from "../../../../src/OpenTelemetry/errors/OTelError"; +import { + OTelInvalidAttributeError, + throwOTelInvalidAttributeError +} from "../../../../src/OpenTelemetry/errors/OTelInvalidAttributeError"; +import { + OTelSpanError, + throwOTelSpanError +} from "../../../../src/OpenTelemetry/errors/OTelSpanError"; + +export class OpenTelemetryErrorsTests extends AITestClass { + + public testInitialize() { + super.testInitialize(); + } + + public testCleanup() { + super.testCleanup(); + } + + public registerTests() { + + // OTelError tests + this.testCase({ + name: "OTelError: getOpenTelemetryError should return a constructor", + test: () => { + // Act + const ErrorConstructor = getOpenTelemetryError(); + + // Assert + Assert.ok(ErrorConstructor, "Constructor should be defined"); + Assert.ok(isFunction(ErrorConstructor), "Constructor should be a function"); + } + }); + + this.testCase({ + name: "OTelError: should create error instance with message", + test: () => { + // Arrange + const ErrorConstructor = getOpenTelemetryError(); + const testMessage = "Test OpenTelemetry error"; + + // Act + const error = new ErrorConstructor(testMessage); + + // Assert + Assert.ok(error instanceof Error, "Should be an instance of Error"); + Assert.ok(error.message === testMessage, "Message should match"); + Assert.ok(error.name === "OpenTelemetryError", "Name should be OpenTelemetryError"); + } + }); + + this.testCase({ + name: "OTelError: should create error instance without message", + test: () => { + // Arrange + const ErrorConstructor = getOpenTelemetryError(); + + // Act + const error = new ErrorConstructor(); + + // Assert + Assert.ok(error instanceof Error, "Should be an instance of Error"); + Assert.ok(error.name === "OpenTelemetryError", "Name should be OpenTelemetryError"); + } + }); + + this.testCase({ + name: "OTelError: throwOTelError should throw OpenTelemetryError", + test: () => { + // Arrange + const testMessage = "Test throw error"; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelError(testMessage); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError instanceof Error, "Should be an instance of Error"); + Assert.ok(caughtError.name === "OpenTelemetryError", "Name should be OpenTelemetryError"); + Assert.ok(caughtError.message === testMessage, "Message should match"); + } + }); + + this.testCase({ + name: "OTelError: should return same constructor instance on multiple calls", + test: () => { + // Act + const constructor1 = getOpenTelemetryError(); + const constructor2 = getOpenTelemetryError(); + + // Assert + Assert.ok(constructor1 === constructor2, "Should return same constructor instance"); + } + }); + + // OTelInvalidAttributeError tests + this.testCase({ + name: "OTelInvalidAttributeError: should throw with message, attribName, and value", + test: () => { + // Arrange + const testMessage = "Invalid attribute error"; + const testAttribName = "invalidAttr"; + const testValue = { invalid: true }; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelInvalidAttributeError(testMessage, testAttribName, testValue); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError instanceof Error, "Should be an instance of Error"); + Assert.ok(caughtError.name === "OTelInvalidAttributeError", "Error name should be OTelInvalidAttributeError"); + Assert.ok(caughtError.message === testMessage, "Message should match"); + Assert.ok((caughtError as OTelInvalidAttributeError).attribName === testAttribName, "Attribute name should match"); + Assert.ok((caughtError as OTelInvalidAttributeError).value === testValue, "Attribute value should match"); + } + }); + + this.testCase({ + name: "OTelInvalidAttributeError: should handle empty parameters", + test: () => { + // Arrange + const testMessage = "Empty attribute error"; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelInvalidAttributeError(testMessage, "", ""); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError.name === "OTelInvalidAttributeError", "Error name should be OTelInvalidAttributeError"); + Assert.ok(caughtError.message === testMessage, "Message should match"); + Assert.ok((caughtError as OTelInvalidAttributeError).attribName === "", "Attribute name should be empty string"); + Assert.ok((caughtError as OTelInvalidAttributeError).value === "", "Attribute value should be empty string"); + } + }); + + this.testCase({ + name: "OTelInvalidAttributeError: should handle undefined parameters", + test: () => { + // Arrange + const testMessage = "Undefined attribute error"; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelInvalidAttributeError(testMessage, undefined as any, undefined); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError.name === "OTelInvalidAttributeError", "Error name should be OTelInvalidAttributeError"); + Assert.ok(caughtError.message === testMessage, "Message should match"); + Assert.ok((caughtError as OTelInvalidAttributeError).attribName === undefined, "Attribute name should be undefined when explicitly passed undefined"); + Assert.ok((caughtError as OTelInvalidAttributeError).value === undefined, "Attribute value should be undefined when explicitly passed undefined"); + } + }); + + this.testCase({ + name: "OTelInvalidAttributeError: should inherit from OpenTelemetryError", + test: () => { + // Arrange + const testMessage = "Inheritance test"; + const OpenTelemetryErrorConstructor = getOpenTelemetryError(); + let caughtError: any = null; + + // Act & Assert + try { + throwOTelInvalidAttributeError(testMessage, "test", "value"); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError instanceof Error, "Should be instance of Error"); + Assert.ok(caughtError instanceof OpenTelemetryErrorConstructor, "Should be instance of OpenTelemetryError"); + Assert.ok(caughtError.name === "OTelInvalidAttributeError", "Should have correct error name"); + } + }); + + this.testCase({ + name: "OTelInvalidAttributeError: attribName property should not conflict with Error.name", + test: () => { + // Arrange + const testMessage = "Property conflict test"; + const testAttribName = "customAttributeName"; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelInvalidAttributeError(testMessage, testAttribName, "value"); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + // Assert that both properties exist and have different values + Assert.ok(caughtError.name === "OTelInvalidAttributeError", "Error.name should be the error type name"); + Assert.ok((caughtError as OTelInvalidAttributeError).attribName === testAttribName, "attribName should be the custom attribute name"); + Assert.ok(caughtError.name !== (caughtError as OTelInvalidAttributeError).attribName, "Error.name and attribName should be different"); + } + }); + + this.testCase({ + name: "OTelInvalidAttributeError: should handle different value types", + test: () => { + // Test different value types + const testCases = [ + { value: null, description: "null value" }, + { value: undefined, description: "undefined value" }, + { value: 0, description: "zero number" }, + { value: false, description: "false boolean" }, + { value: [], description: "empty array" }, + { value: {}, description: "empty object" }, + { value: "string", description: "string value" }, + { value: 123, description: "number value" }, + { value: true, description: "boolean value" } + ]; + + testCases.forEach(testCase => { + let caughtError: any = null; + try { + throwOTelInvalidAttributeError("Test message", "testAttr", testCase.value); + Assert.ok(false, `Should have thrown an error for ${testCase.description}`); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, `Error should have been caught for ${testCase.description}`); + Assert.ok((caughtError as OTelInvalidAttributeError).value === testCase.value, + `Value should match for ${testCase.description}`); + }); + } + }); + + // OTelSpanError tests + this.testCase({ + name: "OTelSpanError: should throw with message and span name", + test: () => { + // Arrange + const testMessage = "Span error occurred"; + const testSpanName = "test-span"; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelSpanError(testMessage, testSpanName); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError instanceof Error, "Should be an instance of Error"); + Assert.ok(caughtError.name === "OTelSpanError", "Error name should be OTelSpanError"); + Assert.ok(caughtError.message === testMessage, "Message should match"); + Assert.ok((caughtError as OTelSpanError).spanName === testSpanName, "Span name should match"); + } + }); + + this.testCase({ + name: "OTelSpanError: should handle empty span name", + test: () => { + // Arrange + const testMessage = "Span error with empty name"; + const testSpanName = ""; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelSpanError(testMessage, testSpanName); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError.message === testMessage, "Message should match"); + Assert.ok((caughtError as OTelSpanError).spanName === testSpanName, "Span name should be empty string"); + } + }); + + this.testCase({ + name: "OTelSpanError: should handle undefined span name", + test: () => { + // Arrange + const testMessage = "Span error with undefined name"; + let caughtError: any = null; + + // Act & Assert + try { + throwOTelSpanError(testMessage, undefined as any); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError.message === testMessage, "Message should match"); + Assert.ok((caughtError as OTelSpanError).spanName === undefined, "Span name should be undefined when explicitly passed undefined"); + } + }); + + this.testCase({ + name: "OTelSpanError: should handle various span name types", + test: () => { + // Test different span name values + const testCases = [ + { spanName: "", expected: "", description: "empty string" }, + { spanName: "valid-span-name", expected: "valid-span-name", description: "valid span name" }, + { spanName: "span with spaces", expected: "span with spaces", description: "span name with spaces" }, + { spanName: "span-with-special-chars!@#", expected: "span-with-special-chars!@#", description: "span name with special characters" } + ]; + + testCases.forEach(testCase => { + let caughtError: any = null; + try { + throwOTelSpanError("Test message", testCase.spanName); + Assert.ok(false, `Should have thrown an error for ${testCase.description}`); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError, `Error should have been caught for ${testCase.description}`); + Assert.ok((caughtError as OTelSpanError).spanName === testCase.expected, + `Span name should be '${testCase.expected}' for ${testCase.description}`); + }); + } + }); + + this.testCase({ + name: "OTelSpanError: should inherit from OpenTelemetryError", + test: () => { + // Arrange + const testMessage = "Inheritance test"; + const OpenTelemetryErrorConstructor = getOpenTelemetryError(); + let caughtError: any = null; + + // Act & Assert + try { + throwOTelSpanError(testMessage, "test-span"); + Assert.ok(false, "Should have thrown an error"); + } catch (error) { + caughtError = error; + } + + Assert.ok(caughtError instanceof OpenTelemetryErrorConstructor, "Should be instance of OpenTelemetryError"); + } + }); + + // Integration tests + this.testCase({ + name: "All error types should have unique names", + test: () => { + // Arrange + let errors: any[] = []; + + // Act - Create instances of all error types + try { + throwOTelError("base error"); + } catch (e) { + errors.push(e); + } + + try { + throwOTelInvalidAttributeError("invalid attr", "key", "value"); + } catch (e) { + errors.push(e); + } + + try { + throwOTelSpanError("span error", "span"); + } catch (e) { + errors.push(e); + } + + // Assert - Check that all error types have unique names + Assert.ok(errors.length === 3, "Should have caught 3 errors"); + Assert.ok(errors[0].name === "OpenTelemetryError", "First error should be OpenTelemetryError"); + Assert.ok(errors[1].name === "OTelInvalidAttributeError", "Second error should be OTelInvalidAttributeError"); + Assert.ok(errors[2].name === "OTelSpanError", "Third error should be OTelSpanError"); + + // Verify all names are unique + const names = errors.map(e => e.name); + const uniqueNames = [...new Set(names)]; + Assert.ok(uniqueNames.length === 3, "All error names should be unique"); + } + }); + + this.testCase({ + name: "Error constructors should be reusable", + test: () => { + // Arrange & Act - Create multiple errors of the same type + let errors: any[] = []; + + try { + throwOTelInvalidAttributeError("first error", "key1", "value1"); + } catch (e) { + errors.push(e); + } + + try { + throwOTelInvalidAttributeError("second error", "key2", "value2"); + } catch (e) { + errors.push(e); + } + + // Assert + Assert.ok(errors.length === 2, "Should have caught 2 errors"); + Assert.ok(errors[0].message === "first error", "First error message should match"); + Assert.ok(errors[1].message === "second error", "Second error message should match"); + Assert.ok(errors[0].name === "OTelInvalidAttributeError", "First error name should be OTelInvalidAttributeError"); + Assert.ok(errors[1].name === "OTelInvalidAttributeError", "Second error name should be OTelInvalidAttributeError"); + Assert.ok(errors[0].attribName === "key1", "First error attribName should match"); + Assert.ok(errors[1].attribName === "key2", "Second error attribName should match"); + Assert.ok(errors[0].value === "value1", "First error value should match"); + Assert.ok(errors[1].value === "value2", "Second error value should match"); + } + }); + + this.testCase({ + name: "Error stack traces should be preserved", + test: () => { + // Arrange + let caughtError: any = null; + + // Act + try { + throwOTelError("Stack trace test"); + } catch (error) { + caughtError = error; + } + + // Assert + Assert.ok(caughtError, "Error should have been caught"); + Assert.ok(caughtError.stack, "Error should have a stack trace"); + Assert.ok(isString(caughtError.stack), "Stack trace should be a string"); + Assert.ok(caughtError.stack.indexOf("OpenTelemetryError") !== -1, "Stack trace should contain error name"); + } + }); + + this.testCase({ + name: "OTelError: dumpObj should contain essential error properties", + test: () => { + // Arrange & Act + const error = new (getOpenTelemetryError())("Test error message"); + const errorDump = dumpObj(error); + + // Assert + Assert.ok(errorDump, "dumpObj should return a string representation"); + Assert.ok(errorDump.indexOf("Test error message") !== -1, `Error dump should contain message: ${errorDump}`); + Assert.ok(errorDump.indexOf("name") !== -1, `Error dump should contain name property: ${errorDump}`); + Assert.ok(errorDump.indexOf("OpenTelemetryError") !== -1, `Error dump should contain error type: ${errorDump}`); + } + }); + + this.testCase({ + name: "OTelInvalidAttributeError: dumpObj should show attribute-specific properties", + test: () => { + // Arrange & Act + let error: any = null; + try { + throwOTelInvalidAttributeError("Invalid attribute test", "testAttribute", { complex: "value" }); + } catch (e) { + error = e; + } + const errorDump = dumpObj(error); + + // Assert + Assert.ok(error, "Error should have been caught"); + Assert.ok(errorDump, "dumpObj should return a string representation"); + Assert.ok(errorDump.indexOf("Invalid attribute test") !== -1, `Error dump should contain message: ${errorDump}`); + Assert.ok(errorDump.indexOf("OTelInvalidAttributeError") !== -1, `Error dump should contain error type: ${errorDump}`); + + // Check properties directly on the error object since dumpObj might not serialize custom properties + Assert.ok(error.attribName === "testAttribute", `Error should have attribName property: ${error.attribName}`); + Assert.ok(error.value !== undefined, `Error should have value property: ${dumpObj(error.value)}`); + + // Ensure the error dump contains meaningful content even if custom properties aren't serialized + Assert.ok(errorDump.length > 50, `Error dump should be substantial: ${errorDump}`); + } + }); + + this.testCase({ + name: "OTelSpanError: dumpObj should show span-specific properties", + test: () => { + // Arrange & Act + let error: any = null; + try { + throwOTelSpanError("Span operation failed", "testSpan"); + } catch (e) { + error = e; + } + const errorDump = dumpObj(error); + + // Assert + Assert.ok(error, "Error should have been caught"); + Assert.ok(errorDump, "dumpObj should return a string representation"); + Assert.ok(errorDump.indexOf("Span operation failed") !== -1, `Error dump should contain message: ${errorDump}`); + Assert.ok(errorDump.indexOf("OTelSpanError") !== -1, `Error dump should contain error type: ${errorDump}`); + + // Check properties directly on the error object since dumpObj might not serialize custom properties + Assert.ok(error.spanName === "testSpan", `Error should have spanName property: ${error.spanName}`); + + // Ensure the error dump contains meaningful content even if custom properties aren't serialized + Assert.ok(errorDump.length > 50, `Error dump should be substantial: ${errorDump}`); + } + }); + + this.testCase({ + name: "Error serialization: dumpObj should handle complex attribute values", + test: () => { + // Arrange & Act + const complexValue = { + nested: { deep: "value" }, + array: [1, 2, 3], + number: 42, + boolean: true, + nullValue: null, + undefinedValue: undefined + }; + let error: any = null; + try { + throwOTelInvalidAttributeError("Complex value test", "complexAttr", complexValue); + } catch (e) { + error = e; + } + const errorDump = dumpObj(error); + + // Assert + Assert.ok(error, "Error should have been caught"); + Assert.ok(errorDump, "dumpObj should return a string representation"); + Assert.ok(errorDump.indexOf("Complex value test") !== -1, `Error dump should contain message: ${errorDump}`); + + // Check the actual property values directly + Assert.ok(error.attribName === "complexAttr", `Error should have correct attribName: ${error.attribName}`); + Assert.ok(error.value === complexValue, `Error should have correct value reference: ${dumpObj(error.value)}`); + + // Dump should be meaningful even if it doesn't contain all custom properties + Assert.ok(errorDump.length > 50, `Error dump should be substantial: ${errorDump}`); + } + }); + + this.testCase({ + name: "Error serialization: dumpObj should be different for different error types", + test: () => { + // Arrange & Act + const baseError = new (getOpenTelemetryError())("Base error"); + + let invalidAttrError: any = null; + try { + throwOTelInvalidAttributeError("Invalid attr", "attr", "value"); + } catch (e) { + invalidAttrError = e; + } + + let spanError: any = null; + try { + throwOTelSpanError("Span error", "span"); + } catch (e) { + spanError = e; + } + + const baseDump = dumpObj(baseError); + const invalidAttrDump = dumpObj(invalidAttrError); + const spanDump = dumpObj(spanError); + + // Assert - Each dump should be unique based on error type and message + Assert.notEqual(baseDump, invalidAttrDump, "Base and InvalidAttribute dumps should be different"); + Assert.notEqual(baseDump, spanDump, "Base and Span dumps should be different"); + Assert.notEqual(invalidAttrDump, spanDump, "InvalidAttribute and Span dumps should be different"); + + // Each should contain their specific error type names + Assert.ok(baseDump.indexOf("OpenTelemetryError") !== -1, `Base error dump should contain type: ${baseDump}`); + Assert.ok(invalidAttrDump.indexOf("OTelInvalidAttributeError") !== -1, `InvalidAttribute dump should contain type: ${invalidAttrDump}`); + Assert.ok(spanDump.indexOf("OTelSpanError") !== -1, `Span dump should contain type: ${spanDump}`); + + // Verify error objects have the expected custom properties (beyond what dumpObj shows) + Assert.ok(invalidAttrError.attribName === "attr", `InvalidAttribute error should have attribName: ${invalidAttrError.attribName}`); + Assert.ok(invalidAttrError.value === "value", `InvalidAttribute error should have value: ${invalidAttrError.value}`); + Assert.ok(spanError.spanName === "span", `Span error should have spanName: ${spanError.spanName}`); + } + }); + + this.testCase({ + name: "Error serialization: dumpObj should handle empty and null values gracefully", + test: () => { + // Arrange & Act + const emptyMsgError = new (getOpenTelemetryError())(""); + + let nullAttrError: any = null; + try { + throwOTelInvalidAttributeError("Test", null as any, null); + } catch (e) { + nullAttrError = e; + } + + let undefinedSpanError: any = null; + try { + throwOTelSpanError("Test", undefined as any); + } catch (e) { + undefinedSpanError = e; + } + + const emptyDump = dumpObj(emptyMsgError); + const nullDump = dumpObj(nullAttrError); + const undefinedDump = dumpObj(undefinedSpanError); + + // Assert - Should not throw and should contain valid representations + Assert.ok(emptyDump, "Empty message error should have valid dump"); + Assert.ok(nullDump, "Null attribute error should have valid dump"); + Assert.ok(undefinedDump, "Undefined span error should have valid dump"); + + // Should contain error type names even with empty values + Assert.ok(emptyDump.indexOf("OpenTelemetryError") !== -1, `Empty dump should contain error type: ${emptyDump}`); + Assert.ok(nullDump.indexOf("OTelInvalidAttributeError") !== -1, `Null dump should contain error type: ${nullDump}`); + Assert.ok(undefinedDump.indexOf("OTelSpanError") !== -1, `Undefined dump should contain error type: ${undefinedDump}`); + + // Verify actual property values (not assuming they're converted to empty strings) + Assert.ok(nullAttrError.attribName === null, `Null attribute error should have null attribName: ${nullAttrError.attribName}`); + Assert.ok(nullAttrError.value === null, `Null attribute error should have null value: ${nullAttrError.value}`); + Assert.ok(undefinedSpanError.spanName === undefined, `Undefined span error should have undefined spanName: ${undefinedSpanError.spanName}`); + } + }); + + this.testCase({ + name: "Error serialization: dumpObj should preserve inheritance chain information", + test: () => { + // Arrange & Act + let invalidAttrError: any = null; + try { + throwOTelInvalidAttributeError("Test", "attr", "val"); + } catch (e) { + invalidAttrError = e; + } + const errorDump = dumpObj(invalidAttrError); + + // Assert - Should show inheritance information + Assert.ok(invalidAttrError, "Error should have been caught"); + Assert.ok(errorDump, "Error dump should exist"); + Assert.ok(invalidAttrError instanceof Error, "Should be instance of Error"); + Assert.ok(invalidAttrError instanceof (getOpenTelemetryError()), "Should be instance of OpenTelemetryError"); + + // Dump should reflect the actual error type, not just base Error + Assert.ok(errorDump.indexOf("OTelInvalidAttributeError") !== -1, `Dump should show specific error type: ${errorDump}`); + + // Should have standard Error properties + Assert.ok(errorDump.indexOf("message") !== -1 || errorDump.indexOf("Test") !== -1, `Dump should contain message content: ${errorDump}`); + Assert.ok(errorDump.indexOf("name") !== -1 || errorDump.indexOf("OTelInvalidAttributeError") !== -1, `Dump should contain name/type information: ${errorDump}`); + + // Verify custom properties exist on the error object + Assert.ok(invalidAttrError.attribName === "attr", `Error should have attribName property: ${invalidAttrError.attribName}`); + Assert.ok(invalidAttrError.value === "val", `Error should have value property: ${invalidAttrError.value}`); + } + }); + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/otelNegative.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/otelNegative.Tests.ts new file mode 100644 index 000000000..d440d9b0a --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/otelNegative.Tests.ts @@ -0,0 +1,690 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { AppInsightsCore } from "../../../../src/applicationinsights-core-js"; +import { IAppInsightsCore } from "../../../../src/JavaScriptSDK.Interfaces/IAppInsightsCore"; +import { createOTelApi } from "../../../../src/OpenTelemetry/otelApi"; +import { IOTelApi } from "../../../../src/OpenTelemetry/interfaces/IOTelApi"; +import { IOTelApiCtx } from "../../../../src/OpenTelemetry/interfaces/IOTelApiCtx"; +import { createNonRecordingSpan, wrapSpanContext, withSpan, useSpan, isTracingSuppressed, suppressTracing, unsuppressTracing } from "../../../../src/OpenTelemetry/trace/utils"; +import { createDistributedTraceContext } from "../../../../src/JavaScriptSDK/TelemetryHelpers"; +import { IChannelControls } from "../../../../src/JavaScriptSDK.Interfaces/IChannelControls"; + +/** + * Negative tests for OpenTelemetry SDK helpers in AppInsights Core + * These tests ensure that no exceptions are thrown and helpers behave correctly + * when there is no trace provider or support instances + */ +export class OTelNegativeTests extends AITestClass { + private _core: IAppInsightsCore = null as any; + + public testInitialize() { + // Create a minimal mock channel to satisfy core initialization requirements + const mockChannel: IChannelControls = { + pause: () => {}, + resume: () => {}, + flush: () => {}, + teardown: () => {}, + processTelemetry: () => {}, + initialize: () => {}, + identifier: "mockChannel", + priority: 1001 + } as any; + + this._core = new AppInsightsCore(); + this._core.initialize({ + instrumentationKey: "00000000-0000-0000-0000-000000000000", + disableInstrumentationKeyValidation: true, + traceCfg: {}, + errorHandlers: {} + }, [mockChannel]); + } + + public testCleanup() { + if (this._core) { + this._core.unload(false); + this._core = null as any; + } + } + + public registerTests() { + this.addCoreWithoutTraceProviderTests(); + this.addTraceApiWithNullCoreTests(); + this.addOTelApiWithInvalidContextTests(); + this.addUtilsWithNullParametersTests(); + this.addSpanOperationsWithoutProviderTests(); + this.addTraceCfgNullHandlingTests(); + } + + private addCoreWithoutTraceProviderTests(): void { + this.testCase({ + name: "Core.startSpan: should return null when no trace provider is set", + test: () => { + // Act - core initialized but no trace provider set + const span = this._core.startSpan("test-span"); + + // Assert - should return null gracefully without throwing + Assert.equal(span, null, "Should return null when no trace provider is available"); + } + }); + + this.testCase({ + name: "Core.getActiveSpan: should return null when no trace provider is set", + test: () => { + // Act + const activeSpan = this._core.getActiveSpan(); + + // Assert + Assert.equal(activeSpan, null, "Should return null when no trace provider is available"); + } + }); + + this.testCase({ + name: "Core.getActiveSpan: should return null when createNew is false without trace provider", + test: () => { + // Act + const activeSpan = this._core.getActiveSpan(false); + + // Assert + Assert.equal(activeSpan, null, "Should return null when createNew is false and no trace provider is available"); + } + }); + + this.testCase({ + name: "Core.getActiveSpan: should return null when createNew is true without trace provider", + test: () => { + // Act + const activeSpan = this._core.getActiveSpan(true); + + // Assert + Assert.equal(activeSpan, null, "Should return null when no trace provider is available regardless of createNew value"); + } + }); + + this.testCase({ + name: "Core.setActiveSpan: should handle gracefully when no trace provider is set", + test: () => { + // Arrange + const mockSpan = { + name: "test", + spanContext: () => createDistributedTraceContext() + } as any; + + // Act - should not throw even without trace provider + const scope = this._core.setActiveSpan(mockSpan); + const activeSpan = this._core.getActiveSpan(); + + // Assert + Assert.ok(scope, "Should return a scope object"); + Assert.equal(scope.host, this._core, "Scope should reference the core"); + Assert.equal(scope.span, mockSpan, "Scope should reference the span"); + Assert.equal(activeSpan, mockSpan, "GetGetGetGetGetGetGetGetGetActiveSpan() should return the same span object"); + + // Restore should be callable without throwing + Assert.doesNotThrow(() => { + scope.restore(); + }, "Restore should not throw when no trace provider is available"); + + // Multiple restore calls should be safe + Assert.doesNotThrow(() => { + scope.restore(); + scope.restore(); + }, "Multiple restore calls should not throw"); + } + }); + + this.testCase({ + name: "Core.getTraceProvider: should return null when no trace provider is set", + test: () => { + // Act + const provider = this._core.getTraceProvider(); + + // Assert + Assert.equal(provider, null, "Should return null when no trace provider is set"); + } + }); + + this.testCase({ + name: "Core.setTraceCtx: should not throw when setting context without trace provider", + test: () => { + // Arrange + const ctx = createDistributedTraceContext(); + ctx.traceId = "12345678901234567890123456789012"; + ctx.spanId = "1234567890123456"; + + // Act & Assert + Assert.doesNotThrow(() => { + this._core.setTraceCtx(ctx); + }, "setTraceCtx should not throw without trace provider"); + + // Verify context was set + const retrievedCtx = this._core.getTraceCtx(); + Assert.equal(retrievedCtx, ctx, "Should retrieve the same context that was set"); + } + }); + + this.testCase({ + name: "Core.setTraceCtx: should handle null context gracefully", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + this._core.setTraceCtx(null); + }, "setTraceCtx should handle null gracefully"); + + const ctx = this._core.getTraceCtx(false); + Assert.equal(ctx, null, "Should return null when set to null and createNew is false"); + } + }); + } + + private addTraceApiWithNullCoreTests(): void { + this.testCase({ + name: "TraceApi: should create otelApi with initialized core", + test: () => { + // Arrange + const otelApiCtx: IOTelApiCtx = { + host: this._core + }; + + let otelApi: IOTelApi; + + // Act & Assert - creation should not throw + Assert.doesNotThrow(() => { + otelApi = createOTelApi(otelApiCtx); + }, "createOTelApi should not throw with initialized core"); + + Assert.ok(otelApi, "Should create otelApi with initialized core"); + } + }); + + this.testCase({ + name: "TraceApi.getActiveSpan: should not return a span without trace provider", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const traceApi = otelApi.trace; + + // Act + const activeSpan = traceApi.getActiveSpan(); + + // Assert + Assert.ok(!activeSpan, "Should not return a span"); + } + }); + + this.testCase({ + name: "TraceApi.setActiveSpan: should handle null span without throwing", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const traceApi = otelApi.trace; + + // Act & Assert - should not throw with null + Assert.doesNotThrow(() => { + traceApi.setActiveSpan(null); + }, "setActiveSpan should handle null gracefully"); + + // Should not throw with undefined + Assert.doesNotThrow(() => { + traceApi.setActiveSpan(undefined); + }, "setActiveSpan should handle undefined gracefully"); + } + }); + + this.testCase({ + name: "TraceApi.setActiveSpan: should handle span without spanContext method", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const traceApi = otelApi.trace; + + const invalidSpan = { + name: "test" + // Missing spanContext method + } as any; + + // Act & Assert - should handle gracefully + let scope: any; + Assert.doesNotThrow(() => { + scope = traceApi.setActiveSpan(invalidSpan); + }, "Should handle span without spanContext method"); + + // Validate scope was returned and references the span + Assert.ok(scope, "Scope should be returned"); + Assert.equal(scope.span, invalidSpan, "Scope.span should equal the invalid span"); + + // Validate activeSpan returns the span + const activeSpan = this._core.getActiveSpan(); + Assert.equal(activeSpan, invalidSpan, "GetGetGetGetGetGetGetGetGetActiveSpan() should return the invalid span"); + } + }); + + this.testCase({ + name: "TraceApi.setActiveSpan: should handle span with legacy context() method", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const traceApi = otelApi.trace; + + const legacySpan = { + name: "legacy", + context: () => ({ + traceId: "12345678901234567890123456789012", + spanId: "1234567890123456", + traceFlags: 1 + }) + } as any; + + // Act & Assert - should handle legacy API + let scope: any; + Assert.doesNotThrow(() => { + scope = traceApi.setActiveSpan(legacySpan); + }, "Should handle legacy span with context() method"); + + // Validate scope was returned and references the span + Assert.ok(scope, "Scope should be returned"); + Assert.equal(scope.span, legacySpan, "Scope.span should equal the legacy span"); + + // Validate activeSpan returns the span + const activeSpan = this._core.getActiveSpan(); + Assert.equal(activeSpan, legacySpan, "GetGetGetGetGetGetGetGetGetActiveSpan() should return the legacy span"); + } + }); + } + + private addOTelApiWithInvalidContextTests(): void { + this.testCase({ + name: "TracerProvider.getTracer: should return tracer without trace provider", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + + // Act & Assert - should not throw when getting tracer + Assert.doesNotThrow(() => { + const tracer = otelApi.getTracer("test-component"); + Assert.ok(tracer, "Should return a tracer instance"); + }, "getTracer should not throw without trace provider"); + } + }); + + this.testCase({ + name: "Tracer.startSpan: should return null without trace provider", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const tracer = otelApi.getTracer("test"); + + // Act + const span = tracer.startSpan("operation"); + + // Assert + Assert.equal(span, null, "Should return null when trace provider is not available"); + } + }); + + this.testCase({ + name: "Tracer.startActiveSpan: should handle missing trace provider", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const tracer = otelApi.getTracer("test"); + let callbackExecuted = false; + + // Act + const result = tracer.startActiveSpan("operation", (span) => { + callbackExecuted = true; + return "result"; + }); + + // Assert - callback should not execute when span creation fails + Assert.ok(!callbackExecuted, "Callback should not execute when span is null"); + Assert.equal(result, undefined, "Should return undefined when no span created"); + } + }); + + this.testCase({ + name: "TracerProvider.shutdown: should not throw without trace provider", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + + // Act & Assert - shutdown should not throw + Assert.doesNotThrow(() => { + otelApi.shutdown(); + }, "shutdown should not throw without trace provider"); + } + }); + + this.testCase({ + name: "TracerProvider.forceFlush: should not throw without trace provider", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + + // Act & Assert + Assert.doesNotThrow(() => { + otelApi.forceFlush(); + }, "forceFlush should not throw without trace provider"); + } + }); + } + + private addUtilsWithNullParametersTests(): void { + this.testCase({ + name: "createNonRecordingSpan: should handle null spanContext gracefully", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + + // Act & Assert + Assert.doesNotThrow(() => { + const span = createNonRecordingSpan(otelApi, "test", null as any); + Assert.ok(span, "Should create span even with null context"); + Assert.ok(!span.isRecording(), "Should be non-recording"); + }, "createNonRecordingSpan should handle null context"); + } + }); + + this.testCase({ + name: "wrapSpanContext: should handle minimal context object", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const minimalContext = { + traceId: "12345678901234567890123456789012", + spanId: "1234567890123456" + } as any; + + // Act & Assert + Assert.doesNotThrow(() => { + const wrapped = wrapSpanContext(otelApi, minimalContext); + Assert.ok(wrapped, "Should wrap minimal context"); + Assert.ok(!wrapped.isRecording(), "Should be non-recording"); + }, "wrapSpanContext should handle minimal context"); + } + }); + + this.testCase({ + name: "withSpan: should execute callback with core", + test: () => { + // Arrange + const mockSpan = { + name: "test", + spanContext: () => createDistributedTraceContext() + } as any; + + let callbackExecuted = false; + const callback = () => { + callbackExecuted = true; + return "result"; + }; + + // Act & Assert + Assert.doesNotThrow(() => { + const result = withSpan(this._core, mockSpan, callback); + Assert.ok(callbackExecuted, "Callback should execute"); + Assert.equal(result, "result", "Should return callback result"); + }, "withSpan should work without trace provider"); + } + }); + + this.testCase({ + name: "useSpan: should provide scope to callback", + test: () => { + // Arrange + const mockSpan = { + name: "test" + } as any; + + let scopeReceived = false; + const callback = (scope: any) => { + scopeReceived = scope !== undefined; + return "result"; + }; + + // Act & Assert + Assert.doesNotThrow(() => { + const result = useSpan(this._core, mockSpan, callback); + Assert.ok(scopeReceived, "Callback should receive scope"); + Assert.equal(result, "result", "Should return callback result"); + }, "useSpan should work without trace provider"); + } + }); + + this.testCase({ + name: "withSpan: should handle callback throwing exception", + test: () => { + // Arrange + const ctx = createDistributedTraceContext(); + ctx.traceId = "12345678901234567890123456789012"; + ctx.spanId = "1234567890123456"; + + const mockSpan = { + name: "test", + spanContext: () => ctx + } as any; + + const callback = () => { + throw new Error("Test error"); + }; + + // Act & Assert + Assert.throws(() => { + withSpan(this._core, mockSpan, callback); + }, (err: Error) => err.message === "Test error", "Should propagate callback exception"); + } + }); + + this.testCase({ + name: "useSpan: should restore scope even when callback throws", + test: () => { + // Arrange + const mockSpan = { name: "test" } as any; + const callback = () => { + throw new Error("Test error"); + }; + + // Act & Assert - should throw but ensure cleanup happens + Assert.throws(() => { + useSpan(this._core, mockSpan, callback); + }, (err: Error) => err.message === "Test error", "Should propagate exception"); + } + }); + } + + private addSpanOperationsWithoutProviderTests(): void { + this.testCase({ + name: "NonRecordingSpan: operations should not throw", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const ctx = createDistributedTraceContext(); + ctx.traceId = "12345678901234567890123456789012"; + ctx.spanId = "1234567890123456"; + const span = createNonRecordingSpan(otelApi, "test", ctx); + + // Act & Assert - all operations should be safe + Assert.doesNotThrow(() => { + span.setAttribute("key", "value"); + span.setAttributes({ key1: "value1", key2: "value2" }); + span.setStatus({ code: 0 }); + span.updateName("new-name"); + span.recordException(new Error("test")); + span.end(); + }, "Non-recording span operations should not throw"); + } + }); + + this.testCase({ + name: "NonRecordingSpan: multiple end calls should be safe", + test: () => { + // Arrange + const otelApi = createOTelApi({ host: this._core }); + const ctx = createDistributedTraceContext(); + ctx.traceId = "12345678901234567890123456789012"; + ctx.spanId = "1234567890123456"; + const span = createNonRecordingSpan(otelApi, "test", ctx); + + // Act & Assert + Assert.doesNotThrow(() => { + span.end(); + span.end(); + span.end(); + }, "Multiple end calls should be safe"); + } + }); + + this.testCase({ + name: "Span: operations after end should log errors but not throw", + test: () => { + // Arrange + const otelApi = createOTelApi({ + host: this._core + }); + + const ctx = createDistributedTraceContext(); + ctx.traceId = "12345678901234567890123456789012"; + ctx.spanId = "1234567890123456"; + const span = createNonRecordingSpan(otelApi, "test", ctx); + span.end(); + + // Act & Assert - operations after end should not throw + Assert.doesNotThrow(() => { + span.setAttribute("key", "value"); + span.setAttributes({ key: "value" }); + }, "Operations after end should not throw"); + } + }); + } + + private addTraceCfgNullHandlingTests(): void { + this.testCase({ + name: "suppressTracing: should handle null context gracefully", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const result = suppressTracing(null as any); + Assert.equal(result, null, "Should return the input context"); + }, "suppressTracing should handle null gracefully"); + } + }); + + this.testCase({ + name: "unsuppressTracing: should handle null context gracefully", + test: () => { + // Act & Assert + Assert.doesNotThrow(() => { + const result = unsuppressTracing(null as any); + Assert.equal(result, null, "Should return the input context"); + }, "unsuppressTracing should handle null gracefully"); + } + }); + + this.testCase({ + name: "isTracingSuppressed: should return false for null context", + test: () => { + // Act + const result = isTracingSuppressed(null as any); + + // Assert + Assert.equal(result, false, "Should return false when context is null"); + } + }); + + this.testCase({ + name: "suppressTracing: should handle context without traceCfg", + test: () => { + // Arrange + const contextWithoutTraceCfg = {} as any; + + // Act & Assert + Assert.doesNotThrow(() => { + suppressTracing(contextWithoutTraceCfg); + }, "Should handle context without traceCfg"); + + // Should not affect the context when traceCfg is missing + Assert.equal(isTracingSuppressed(contextWithoutTraceCfg), false, + "Should return false when traceCfg is missing"); + } + }); + + this.testCase({ + name: "suppressTracing: should work with core instance", + test: () => { + // Arrange + this._core.config.traceCfg = {}; + + // Act + suppressTracing(this._core); + + // Assert + Assert.equal(isTracingSuppressed(this._core), true, + "Should suppress tracing on core instance"); + + // Cleanup + unsuppressTracing(this._core); + Assert.equal(isTracingSuppressed(this._core), false, + "Should unsuppress tracing on core instance"); + } + }); + + this.testCase({ + name: "suppressTracing: should work with otelApi instance", + test: () => { + // Arrange + this._core.config.traceCfg = {}; + const otelApi = createOTelApi({ host: this._core }); + + // Act + suppressTracing(otelApi); + + // Assert + Assert.equal(isTracingSuppressed(otelApi), true, + "Should suppress tracing on otelApi instance"); + + // Cleanup + unsuppressTracing(otelApi); + } + }); + + this.testCase({ + name: "suppressTracing: should work with config instance", + test: () => { + // Arrange + const config = { + instrumentationKey: "test", + traceCfg: {} + } as any; + + // Act + suppressTracing(config); + + // Assert + Assert.equal(isTracingSuppressed(config), true, + "Should suppress tracing on config instance"); + + // Cleanup + unsuppressTracing(config); + } + }); + + this.testCase({ + name: "isTracingSuppressed: should handle undefined traceCfg properties", + test: () => { + // Arrange + const config = { + traceCfg: { + // suppressTracing is undefined + } + } as any; + + // Act + const result = isTracingSuppressed(config); + + // Assert + Assert.equal(result, false, "Should return false when suppressTracing is undefined"); + } + }); + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/span.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/span.Tests.ts new file mode 100644 index 000000000..751fb5832 --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/span.Tests.ts @@ -0,0 +1,1701 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { getDeferred, ICachedValue, isNullOrUndefined, mathMin, perfNow } from "@nevware21/ts-utils"; +import { createSpan } from "../../../../src/OpenTelemetry/trace/span"; +import { IOTelSpanCtx } from "../../../../src/OpenTelemetry/interfaces/trace/IOTelSpanCtx"; +import { IOTelApi } from "../../../../src/OpenTelemetry/interfaces/IOTelApi"; +import { IOTelConfig } from "../../../../src/OpenTelemetry/interfaces/config/IOTelConfig"; +import { eOTelSpanStatusCode } from "../../../../src/OpenTelemetry/enums/trace/OTelSpanStatus"; +import { IOTelAttributes } from "../../../../src/OpenTelemetry/interfaces/IOTelAttributes"; +import { IReadableSpan } from "../../../../src/OpenTelemetry/interfaces/trace/IReadableSpan"; +import { IDistributedTraceContext } from "../../../../src/JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { createDistributedTraceContext } from "../../../../src/JavaScriptSDK/TelemetryHelpers"; +import { generateW3CId } from "../../../../src/JavaScriptSDK/CoreUtils"; +import { suppressTracing, unsuppressTracing, isTracingSuppressed, useSpan, withSpan } from "../../../../src/OpenTelemetry/trace/utils"; +import { ITraceCfg } from "../../../../src/OpenTelemetry/interfaces/config/ITraceCfg"; +import { AppInsightsCore } from "../../../../src/JavaScriptSDK/AppInsightsCore"; +import { IConfiguration } from "../../../../src/JavaScriptSDK.Interfaces/IConfiguration"; +import { ITraceProvider, ISpanScope, ITraceHost } from "../../../../src/JavaScriptSDK.Interfaces/ITraceProvider"; +import { IOTelSpanOptions } from "../../../../src/OpenTelemetry/interfaces/trace/IOTelSpanOptions"; +import { eOTelSpanKind } from "../../../../src/OpenTelemetry/enums/trace/OTelSpanKind"; + +export class SpanTests extends AITestClass { + + private _mockApi!: IOTelApi; + private _mockSpanContext!: IDistributedTraceContext; + private _onEndCalls!: IReadableSpan[]; + private _core!: AppInsightsCore; + + public testInitialize() { + super.testInitialize(); + this._onEndCalls = []; + + // Create mock span context + this._mockSpanContext = createDistributedTraceContext({ + traceId: "12345678901234567890123456789012", + spanId: "1234567890123456", + traceFlags: 1, + isRemote: false + }); + + // Create mock API + this._mockApi = { + cfg: { + errorHandlers: {} + } as IOTelConfig + } as IOTelApi; + } + + public testCleanup() { + super.testCleanup(); + this._onEndCalls = []; + + // Clean up AppInsightsCore instance if initialized + if (this._core && this._core.isInitialized()) { + this._core.unload(false); + } + this._core = undefined as any; + } + + /** + * Helper function to create a simple trace provider with onEnd callback + */ + private _createTestTraceProvider(host: ITraceHost, onEnd?: (span: IReadableSpan) => void): ICachedValue { + const actualOnEnd = onEnd || ((span) => this._onEndCalls.push(span)); + + return getDeferred(() => { + const provider: ITraceProvider = { + api: this._mockApi, + createSpan: (name: string, options?: IOTelSpanOptions, parent?: IDistributedTraceContext): IReadableSpan => { + // Create a new distributed trace context for this span + let newCtx: IDistributedTraceContext; + if (parent) { + // For child spans: keep parent's traceId but generate new spanId + newCtx = createDistributedTraceContext({ + traceId: parent.traceId, + spanId: generateW3CId().substring(0, 16), // Generate new 16-char spanId + traceFlags: parent.traceFlags || 0, + isRemote: false + }); + } else { + // For root spans: generate new traceId and spanId + newCtx = createDistributedTraceContext(); + } + + // Get configuration from the core if available, including suppressTracing + let isRecording = options?.recording !== false; + if (this._core && this._core.config && this._core.config.traceCfg && this._core.config.traceCfg.suppressTracing) { + isRecording = false; + } + + // Create the span context + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: newCtx, + attributes: options?.attributes, + startTime: options?.startTime, + isRecording: isRecording, + onEnd: actualOnEnd + }; + + return createSpan(spanCtx, name, options?.kind || eOTelSpanKind.INTERNAL); + }, + getProviderId: (): string => "test-provider", + isAvailable: (): boolean => true + }; + + return provider; + }); + } + + /** + * Helper function to set up AppInsightsCore with trace provider + */ + private _setupCore(config?: Partial): AppInsightsCore { + this._core = new AppInsightsCore(); + + // Create a simple test channel + const testChannel = { + identifier: "TestChannel", + priority: 1001, + initialize: () => {}, + processTelemetry: () => {}, + teardown: () => {}, + isInitialized: () => true + }; + + const coreConfig: IConfiguration = { + instrumentationKey: "test-ikey-12345", + traceCfg: { + serviceName: "test-service" + }, + ...config + }; + + // Initialize the core with the test channel + this._core.initialize(coreConfig, [testChannel]); + + // Set up the trace provider + const traceProvider = this._createTestTraceProvider(this._core); + this._core.setTraceProvider(traceProvider); + + return this._core; + } + + public registerTests() { + this.testCase({ + name: "createSpan: should create span with basic properties", + test: () => { + // Arrange + const spanName = "test-span"; + const spanKind = eOTelSpanKind.CLIENT; + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + + // Act + const span = createSpan(spanCtx, spanName, spanKind); + + // Assert + Assert.ok(span, "Span should be created"); + Assert.equal(span.name, spanName, "Span name should match"); + Assert.equal(span.kind, spanKind, "Span kind should match"); + Assert.equal(span.spanContext().traceId, this._mockSpanContext.traceId, "Trace ID should match"); + Assert.equal(span.spanContext().spanId, this._mockSpanContext.spanId, "Span ID should match"); + Assert.ok(span.isRecording(), "Span should be recording by default"); + Assert.ok(!span.ended, "Span should not be ended initially"); + } + }); + + this.testCase({ + name: "suppressTracing: span with suppressTracing config should not be recording", + test: () => { + // Arrange - create mock API with suppressTracing enabled + const mockApiWithSuppression: IOTelApi = { + cfg: { + traceCfg: { + suppressTracing: true + } as ITraceCfg, + errorHandlers: {} + } as IOTelConfig + } as IOTelApi; + + const spanCtx: IOTelSpanCtx = { + api: mockApiWithSuppression, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + + // Act + const span = createSpan(spanCtx, "suppressed-span", eOTelSpanKind.CLIENT); + + // Assert + Assert.ok(!span.isRecording(), "Span should not be recording when suppressTracing is enabled"); + Assert.ok(span, "Span should still be created"); + Assert.equal(span.name, "suppressed-span", "Span name should be set correctly"); + } + }); + + this.testCase({ + name: "suppressTracing: suppressTracing() helper should set config and return context", + test: () => { + // Arrange - create a context with traceCfg (following IConfiguration pattern) + const testContext = { + traceCfg: { + suppressTracing: false + } as ITraceCfg + }; + + // Act + const returnedContext = suppressTracing(testContext); + + // Assert + Assert.equal(returnedContext, testContext, "suppressTracing should return the same context"); + Assert.ok(testContext.traceCfg.suppressTracing, "suppressTracing config should be set to true"); + Assert.ok(isTracingSuppressed(testContext), "isTracingSuppressed should return true"); + } + }); + + this.testCase({ + name: "suppressTracing: unsuppressTracing() helper should clear config and return context", + test: () => { + // Arrange - create a context with suppressTracing enabled (following IConfiguration pattern) + const testContext = { + traceCfg: { + suppressTracing: true + } as ITraceCfg + }; + + // Act + const returnedContext = unsuppressTracing(testContext); + + // Assert + Assert.equal(returnedContext, testContext, "unsuppressTracing should return the same context"); + Assert.ok(!testContext.traceCfg.suppressTracing, "suppressTracing config should be set to false"); + Assert.ok(!isTracingSuppressed(testContext), "isTracingSuppressed should return false"); + } + }); + + this.testCase({ + name: "createSpan: should create span with options", + test: () => { + // Arrange + const spanName = "test-span-with-options"; + const spanKind = eOTelSpanKind.SERVER; + const attributes: IOTelAttributes = { + "service.name": "test-service", + "http.method": "GET", + "http.status_code": 200 + }; + + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + + // Act + const span = createSpan(spanCtx, spanName, spanKind); + + // Set attributes after creation + span.setAttributes(attributes); + + // Assert + Assert.ok(span, "Span should be created"); + Assert.equal(span.name, spanName, "Span name should match"); + Assert.equal(span.kind, spanKind, "Span kind should match"); + Assert.ok(span.isRecording(), "Span should be recording"); + Assert.ok(!span.ended, "Span should not be ended initially"); + } + }); + + this.testCase({ + name: "createSpan: should end span and call onEnd callback", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + + // Act + span.end(); + + // Assert + Assert.ok(span.ended, "Span should be marked as ended"); + Assert.equal(this._onEndCalls.length, 1, "onEnd callback should be called once"); + Assert.equal(this._onEndCalls[0], span, "onEnd should be called with the span"); + } + }); + + this.testCase({ + name: "createSpan: should set and get attributes", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + + // Act + span.setAttribute("http.method", "POST"); + span.setAttribute("http.status_code", 201); + span.setAttributes({ + "service.name": "test-service", + "user.authenticated": true + }); + + // Assert - Note: The exact attribute retrieval method depends on implementation + // This test verifies that setAttribute and setAttributes don't throw errors + Assert.ok(span, "Span should still be valid after setting attributes"); + Assert.ok(span.isRecording(), "Span should still be recording"); + } + }); + + this.testCase({ + name: "createSpan: should set span status", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + + // Act + span.setStatus({ + code: eOTelSpanStatusCode.ERROR, + message: "Something went wrong" + }); + + // Assert + Assert.ok(span, "Span should still be valid after setting status"); + Assert.ok(span.isRecording(), "Span should still be recording"); + } + }); + + this.testCase({ + name: "createSpan: should handle events (not yet implemented)", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + + // Act & Assert - Currently events are not supported, so this test just verifies span remains valid + // span.addEvent("request.started"); // Not implemented yet + // span.addEvent("response.received", { ... }); // Not implemented yet + + Assert.ok(span, "Span should still be valid"); + Assert.ok(span.isRecording(), "Span should still be recording"); + } + }); + + this.testCase({ + name: "createSpan: should record exception", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + const testError = new Error("Test error"); + + // Act + span.recordException(testError); + span.recordException({ + name: "CustomError", + message: "Custom error message", + stack: "Error stack trace" + }); + + // Assert + Assert.ok(span, "Span should still be valid after recording exceptions"); + Assert.ok(span.isRecording(), "Span should still be recording"); + } + }); + + this.testCase({ + name: "createSpan: should update span name", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "original-name", eOTelSpanKind.CLIENT); + + // Act + span.updateName("updated-name"); + + // Assert + Assert.equal(span.name, "updated-name", "Span name should be updated"); + Assert.ok(span.isRecording(), "Span should still be recording"); + } + }); + + this.testCase({ + name: "createSpan: should handle multiple end calls gracefully", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + + // Act + span.end(); + span.end(); // Second call should be ignored + span.end(); // Third call should be ignored + + // Assert + Assert.ok(span.ended, "Span should be marked as ended"); + Assert.equal(this._onEndCalls.length, 1, "onEnd callback should be called only once"); + } + }); + + this.testCase({ + name: "createSpan: should not record operations after span is ended", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + span.end(); + + // Act & Assert - These operations should not throw but should be ignored + span.setAttribute("test.attr", "test-value"); + // span.addEvent("test.event"); // Not implemented yet + span.setStatus({ code: eOTelSpanStatusCode.ERROR }); + span.updateName("new-name"); + span.recordException(new Error("Test error")); + + // The span name should not change after ending + Assert.equal(span.name, "test-span", "Span name should not change after ending"); + Assert.ok(span.ended, "Span should remain ended"); + Assert.ok(!span.isRecording(), "Span should not be recording after ending"); + } + }); + + this.testCase({ + name: "createSpan: should handle invalid attribute values", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + + // Act & Assert - These should not throw errors + span.setAttribute("", "empty-key"); + span.setAttribute("null-value", null as any); + span.setAttribute("undefined-value", undefined as any); + span.setAttribute("object-value", { nested: "object" } as any); + span.setAttribute("array-value", [1, 2, 3] as any); + + Assert.ok(span, "Span should remain valid after setting invalid attributes"); + Assert.ok(span.isRecording(), "Span should still be recording"); + } + }); + + this.testCase({ + name: "createSpan: should work with different span kinds", + test: () => { + // Test each span kind + const spanKinds = [ + eOTelSpanKind.INTERNAL, + eOTelSpanKind.SERVER, + eOTelSpanKind.CLIENT, + eOTelSpanKind.PRODUCER, + eOTelSpanKind.CONSUMER + ]; + + spanKinds.forEach((kind, index) => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + + // Act + const span = createSpan(spanCtx, `test-span-${index}`, kind); + + // Assert + Assert.ok(span, `Span should be created for kind ${kind}`); + Assert.equal(span.kind, kind, `Span kind should match ${kind}`); + Assert.ok(span.isRecording(), `Span should be recording for kind ${kind}`); + }); + } + }); + + this.testCase({ + name: "createSpan: should handle span context correctly", + test: () => { + // Arrange + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + + // Act + const span = createSpan(spanCtx, "test-span", eOTelSpanKind.CLIENT); + const spanContext = span.spanContext(); + + // Assert + Assert.ok(spanContext, "Span context should be available"); + Assert.equal(spanContext.traceId, this._mockSpanContext.traceId, "Trace ID should match"); + Assert.equal(spanContext.spanId, this._mockSpanContext.spanId, "Span ID should match"); + Assert.equal(spanContext.traceFlags, this._mockSpanContext.traceFlags, "Trace flags should match"); + } + }); + + // === AppInsightsCore Integration Tests === + + this.testCase({ + name: "AppInsightsCore Integration: should create span using core.startSpan with trace provider", + test: () => { + // Arrange + const core = this._setupCore(); + + // Act + const span = core.startSpan("integration-test-span", { + kind: eOTelSpanKind.SERVER, + attributes: { + "service.name": "test-service", + "operation.type": "web-request" + } + }); + + // Assert + Assert.ok(span, "Span should be created via core.startSpan"); + const readableSpan = span! as IReadableSpan; + Assert.equal(readableSpan.name, "integration-test-span", "Span name should match"); + Assert.equal(readableSpan.kind, eOTelSpanKind.SERVER, "Span kind should match"); + Assert.ok(span!.isRecording(), "Span should be recording"); + Assert.ok(!readableSpan.ended, "Span should not be ended initially"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should call onEnd callback when span ends", + test: () => { + // Arrange + const core = this._setupCore(); + const span = core.startSpan("callback-test-span"); + Assert.equal(this._onEndCalls.length, 0, "No onEnd calls initially"); + + // Act + Assert.ok(span, "Span should be created"); + span!.end(); + + // Assert + const readableSpan = span! as IReadableSpan; + Assert.ok(readableSpan.ended, "Span should be ended"); + Assert.equal(this._onEndCalls.length, 1, "onEnd callback should be called once"); + Assert.equal(this._onEndCalls[0].name, "callback-test-span", "onEnd should receive the correct span"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should inherit trace context from core", + test: () => { + // Arrange + const core = this._setupCore(); + + // Set a specific trace context on the core + const parentTraceContext = createDistributedTraceContext({ + traceId: "parent-trace-12345678901234567890123456789012", + spanId: "parent-span-1234567890123456", + traceFlags: 1 + }); + core.setTraceCtx(parentTraceContext); + + // Act + const span = core.startSpan("child-span"); + + // Assert + Assert.ok(span, "Child span should be created"); + const spanContext = span!.spanContext(); + Assert.equal(spanContext.traceId, parentTraceContext.traceId, "Child span should inherit parent trace ID"); + Assert.notEqual(spanContext.spanId, parentTraceContext.spanId, "Child span should have different span ID"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should handle configuration with suppressTracing", + test: () => { + // Arrange + const core = this._setupCore({ + traceCfg: { + suppressTracing: true, + serviceName: "suppressed-service" + } + }); + + // Act + const span = core.startSpan("suppressed-span"); + + // Assert + Assert.ok(span, "Span should still be created when suppressTracing is enabled"); + const readableSpan = span! as IReadableSpan; + Assert.ok(!span!.isRecording(), "Span should not be recording when suppressTracing is enabled"); + Assert.equal(readableSpan.name, "suppressed-span", "Span name should be set correctly"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should support active span management", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + + // Act + const span = core.startSpan("active-span-test"); + Assert.ok(span, "Span should be created"); + + // Debug: Check if trace provider exists + const traceProvider = core.getTraceProvider!(); + Assert.ok(traceProvider, "Trace provider should exist"); + Assert.ok(traceProvider!.isAvailable(), "Trace provider should be available"); + + // Debug: Check trace provider before setActiveSpan + const providerActiveSpanBefore = core.getActiveSpan(); + Assert.equal(providerActiveSpanBefore, initialActiveSpan, "Trace provider should return the initially active span before setActiveSpan"); + + // Manually set as active span (this would normally be done by startActiveSpan) + const scope = core.setActiveSpan(span!); + + // Assert scope object + Assert.ok(scope, "Scope should be returned"); + Assert.equal(scope.span, span, "Scope.span should equal the passed span"); + + // Debug: Check trace provider directly after setActiveSpan + const providerActiveSpanAfter = core.getActiveSpan(); + Assert.ok(providerActiveSpanAfter, "Trace provider should have active span after setActiveSpan"); + Assert.equal(providerActiveSpanAfter, span, "Trace provider active span should be the same instance"); + + // Assert + const activeSpan = core.getActiveSpan(); + Assert.ok(activeSpan, "Active span should be available"); + if (activeSpan) { + const readableActiveSpan = activeSpan as IReadableSpan; + Assert.equal(readableActiveSpan.name, "active-span-test", "Active span should be the correct span"); + Assert.equal(activeSpan, span, "Active span should be the same instance"); + } + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should create child spans with proper parent-child relationship", + test: () => { + // Arrange + const core = this._setupCore(); + + // Act + const parentSpan = core.startSpan("parent-operation", { + kind: eOTelSpanKind.SERVER, + attributes: { "operation.name": "process-request" } + }); + Assert.ok(parentSpan, "Parent span should be created"); + + // Set parent as active + const scope = core.setActiveSpan(parentSpan!); + const activeSpan = core.getActiveSpan(); + + // Assert scope and activeSpan + Assert.ok(scope, "Scope should be returned"); + Assert.equal(scope.span, parentSpan, "Scope.span should equal the parent span"); + Assert.equal(activeSpan, parentSpan, "GetGetGetGetGetGetGetGetGetActiveSpan() should return the parent span"); + + const childSpan = core.startSpan("child-operation", { + kind: eOTelSpanKind.CLIENT, + attributes: { "operation.name": "database-query" } + }); + Assert.ok(childSpan, "Child span should be created"); + + // Assert + const parentContext = parentSpan!.spanContext(); + const childContext = childSpan!.spanContext(); + + Assert.equal(childContext.traceId, parentContext.traceId, "Child should have same trace ID as parent"); + Assert.notEqual(childContext.spanId, parentContext.spanId, "Child should have different span ID from parent"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should handle span attributes and status correctly", + test: () => { + // Arrange + const core = this._setupCore(); + const span = core.startSpan("attribute-test-span", { + attributes: { + "initial.attribute": "initial-value" + } + }); + + // Act + Assert.ok(span, "Span should be created"); + span!.setAttribute("http.method", "POST"); + span!.setAttribute("http.status_code", 201); + span!.setAttributes({ + "service.version": "1.2.3", + "user.authenticated": true + }); + + span!.setStatus({ + code: eOTelSpanStatusCode.OK, + message: "Operation completed successfully" + }); + + // Assert + const readableSpan = span! as IReadableSpan; + Assert.ok(span!.isRecording(), "Span should still be recording"); + Assert.ok(!readableSpan.ended, "Span should not be ended"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should handle span lifecycle properly", + test: () => { + // Arrange + const core = this._setupCore(); + const span = core.startSpan("lifecycle-test-span"); + Assert.equal(this._onEndCalls.length, 0, "No onEnd calls initially"); + + // Act - Perform operations during span lifetime + Assert.ok(span, "Span should be created"); + span!.setAttribute("test.phase", "active"); + span!.recordException(new Error("Test exception for logging")); + span!.updateName("lifecycle-test-span-updated"); + + // End the span + span!.end(); + + // Assert + const readableSpan = span! as IReadableSpan; + Assert.ok(readableSpan.ended, "Span should be ended"); + Assert.equal(readableSpan.name, "lifecycle-test-span-updated", "Span name should be updated"); + Assert.ok(!span!.isRecording(), "Span should not be recording after ending"); + Assert.equal(this._onEndCalls.length, 1, "onEnd callback should be called once"); + + // Verify operations after end are ignored + span!.setAttribute("test.phase", "completed"); + span!.updateName("should-not-change"); + Assert.equal(readableSpan.name, "lifecycle-test-span-updated", "Span name should not change after ending"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should handle multiple spans and proper cleanup", + test: () => { + // Arrange + const core = this._setupCore(); + const spans: IReadableSpan[] = []; + + // Act - Create multiple spans + for (let i = 0; i < 5; i++) { + const span = core.startSpan(`batch-span-${i}`, { + kind: eOTelSpanKind.INTERNAL, + attributes: { + "span.index": i, + "batch.id": "test-batch-123" + } + }); + Assert.ok(span, `Span ${i} should be created`); + spans.push(span!); + } + + // End all spans + spans.forEach(span => span.end()); + + // Assert + Assert.equal(spans.length, 5, "Should have created 5 spans"); + Assert.equal(this._onEndCalls.length, 5, "Should have 5 onEnd callback calls"); + + spans.forEach((span, index) => { + const readableSpan = span as IReadableSpan; + Assert.ok(readableSpan.ended, `Span ${index} should be ended`); + Assert.equal(readableSpan.name, `batch-span-${index}`, `Span ${index} should have correct name`); + }); + + // Verify all spans in onEnd calls + this._onEndCalls.forEach((readableSpan, index) => { + Assert.equal(readableSpan.name, `batch-span-${index}`, `onEnd span ${index} should have correct name`); + }); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should handle trace provider configuration changes", + test: () => { + // Arrange + const core = this._setupCore(); + const originalProvider = core.getTraceProvider(); + Assert.ok(originalProvider, "Original trace provider should be available"); + + // Act - Create new trace provider with different onEnd behavior + let alternativeOnEndCalls: IReadableSpan[] = []; + const newProvider = this._createTestTraceProvider(core, (span) => { + alternativeOnEndCalls.push(span); + }); + + core.setTraceProvider(newProvider); + const updatedProvider = core.getTraceProvider(); + + // Create spans with new provider + const span1 = core.startSpan("provider-change-test-1"); + Assert.ok(span1, "Span should be created with new provider"); + span1!.end(); + + // Assert + Assert.notEqual(updatedProvider, originalProvider, "Trace provider should be updated"); + Assert.equal(this._onEndCalls.length, 0, "Original onEnd callback should not be called"); + Assert.equal(alternativeOnEndCalls.length, 1, "New onEnd callback should be called"); + Assert.equal(alternativeOnEndCalls[0].name, "provider-change-test-1", "New callback should receive correct span"); + } + }); + + this.testCase({ + name: "AppInsightsCore Integration: should handle configuration inheritance from core config", + test: () => { + // Arrange + const core = this._setupCore({ + traceCfg: { + serviceName: "integration-test-service", + generalLimits: { + attributeCountLimit: 64, + attributeValueLengthLimit: 256 + // }, + // spanLimits: { + // attributeCountLimit: 32, + // eventCountLimit: 16, + // linkCountLimit: 8 + } + } + }); + + // Act + const span = core.startSpan("config-inheritance-test", { + attributes: { + "service.name": "integration-test-service", // Should inherit from traceCfg + "test.configured": true + } + }); + + // Assert + Assert.ok(span, "Span should be created with inherited configuration"); + const readableSpan = span! as IReadableSpan; + Assert.ok(span!.isRecording(), "Span should be recording"); + + // The span should exist and be functional - detailed config validation + // would require access to internal span configuration which is not exposed + Assert.equal(readableSpan.name, "config-inheritance-test", "Span name should be correct"); + } + }); + + // === withSpan Helper Tests === + + this.testCase({ + name: "withSpan: should execute function with span as active span", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const testSpan = core.startSpan("withSpan-test-active"); + Assert.ok(testSpan, "Test span should be created"); + + let capturedActiveSpan: IReadableSpan | null = null; + const testFunction = () => { + capturedActiveSpan = core.getActiveSpan(); + return "test-result"; + }; + + // Act + const result = withSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, "test-result", "withSpan should return function result"); + Assert.ok(capturedActiveSpan, "Function should have access to active span"); + Assert.equal(capturedActiveSpan, testSpan, "Active span should be the provided span"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "Active span should be restored after execution"); + } + }); + + this.testCase({ + name: "withSpan: should restore previous active span after execution", + test: () => { + // Arrange + const core = this._setupCore(); + const previousSpan = core.startSpan("previous-span"); + const testSpan = core.startSpan("withSpan-test-restore"); + Assert.ok(previousSpan && testSpan, "Both spans should be created"); + + // Set previous span as active + core.setActiveSpan(previousSpan!); + Assert.equal(core.getActiveSpan(), previousSpan, "Previous span should be active initially"); + + let capturedActiveSpan: IReadableSpan | null = null; + const testFunction = () => { + capturedActiveSpan = core.getActiveSpan(); + return 42; + }; + + // Act + const result = withSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, 42, "withSpan should return function result"); + Assert.equal(capturedActiveSpan, testSpan, "Function should have access to test span"); + Assert.equal(core.getActiveSpan(), previousSpan, "Previous active span should be restored"); + } + }); + + this.testCase({ + name: "withSpan: should handle function with arguments", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("withSpan-test-args"); + Assert.ok(testSpan, "Test span should be created"); + + let capturedArgs: any[] = []; + const testFunction = (...args: any[]) => { + capturedArgs = args; + return args.reduce((sum, val) => sum + val, 0); + }; + + // Act + const result = withSpan(core, testSpan!, testFunction, undefined, 10, 20, 30); + + // Assert + Assert.equal(result, 60, "withSpan should return correct sum"); + Assert.equal(capturedArgs.length, 3, "Function should receive all arguments"); + Assert.equal(capturedArgs[0], 10, "First argument should be correct"); + Assert.equal(capturedArgs[1], 20, "Second argument should be correct"); + Assert.equal(capturedArgs[2], 30, "Third argument should be correct"); + } + }); + + this.testCase({ + name: "withSpan: should handle function with thisArg context", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("withSpan-test-this"); + Assert.ok(testSpan, "Test span should be created"); + + const contextObject = { + value: 100, + getValue: function(multiplier: number) { + return this.value * multiplier; + } + }; + + // Act + const result = withSpan(core, testSpan!, contextObject.getValue, contextObject, 2); + + // Assert + Assert.equal(result, 200, "withSpan should execute with correct this context"); + } + }); + + this.testCase({ + name: "withSpan: should handle exceptions and still restore active span", + test: () => { + // Arrange + const core = this._setupCore(); + const previousSpan = core.startSpan("previous-span-exception"); + const testSpan = core.startSpan("withSpan-test-exception"); + Assert.ok(previousSpan && testSpan, "Both spans should be created"); + + core.setActiveSpan(previousSpan!); + + const testFunction = () => { + throw new Error("Test exception"); + }; + + // Act & Assert + let thrownError: Error | null = null; + try { + withSpan(core, testSpan!, testFunction); + } catch (error) { + thrownError = error as Error; + } + + Assert.ok(thrownError, "Exception should be thrown"); + Assert.equal(thrownError!.message, "Test exception", "Exception message should be preserved"); + Assert.equal(core.getActiveSpan(), previousSpan, "Previous active span should be restored even after exception"); + } + }); + + this.testCase({ + name: "withSpan: should work with functions returning different types", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("withSpan-test-types"); + Assert.ok(testSpan, "Test span should be created"); + + // Test string return + const stringResult = withSpan(core, testSpan!, () => "hello world"); + Assert.equal(stringResult, "hello world", "String return should work"); + + // Test number return + const numberResult = withSpan(core, testSpan!, () => 123.45); + Assert.equal(numberResult, 123.45, "Number return should work"); + + // Test boolean return + const booleanResult = withSpan(core, testSpan!, () => true); + Assert.equal(booleanResult, true, "Boolean return should work"); + + // Test object return + const objectResult = withSpan(core, testSpan!, () => ({ key: "value" })); + Assert.ok(objectResult && objectResult.key === "value", "Object return should work"); + + // Test undefined return + const undefinedResult = withSpan(core, testSpan!, () => undefined); + Assert.equal(undefinedResult, undefined, "Undefined return should work"); + + // Test null return + const nullResult = withSpan(core, testSpan!, () => null); + Assert.equal(nullResult, null, "Null return should work"); + } + }); + + this.testCase({ + name: "withSpan: should work with async-like function patterns", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const testSpan = core.startSpan("withSpan-test-async-pattern"); + Assert.ok(testSpan, "Test span should be created"); + + let spanDuringExecution: IReadableSpan | null = null; + + // Simulate async-like pattern with callback + const asyncFunction = (callback: (result: string) => void) => { + spanDuringExecution = core.getActiveSpan(); + // Simulate some async work completing synchronously for this test + callback("async-result"); + return "function-result"; + }; + + let callbackResult = ""; + const callback = (result: string) => { + callbackResult = result; + }; + + // Act + const result = withSpan(core, testSpan!, asyncFunction, undefined, callback); + + // Assert + Assert.equal(result, "function-result", "withSpan should return main function result"); + Assert.equal(callbackResult, "async-result", "Callback should be executed"); + Assert.equal(spanDuringExecution, testSpan, "Active span should be available during execution"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "Active span should be restored after completion"); + } + }); + + this.testCase({ + name: "withSpan: should work when no previous active span exists", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + const testSpan = core.startSpan("withSpan-test-no-previous"); + Assert.ok(testSpan, "Test span should be created"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "Just starting a span should not change active span"); + + let capturedActiveSpan: IReadableSpan | null = null; + const testFunction = () => { + capturedActiveSpan = core.getActiveSpan(); + return "success"; + }; + + // Act + const result = withSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, "success", "Function should execute successfully"); + Assert.equal(capturedActiveSpan, testSpan, "Test span should be active during execution"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "No active span should be restored"); + } + }); + + this.testCase({ + name: "withSpan: should work with nested withSpan calls", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const outerSpan = core.startSpan("outer-span"); + const innerSpan = core.startSpan("inner-span"); + Assert.ok(outerSpan && innerSpan, "Both spans should be created"); + + const executionTrace: string[] = []; + + const innerFunction = () => { + const activeSpan = core.getActiveSpan(); + executionTrace.push(`inner: ${activeSpan ? (activeSpan as IReadableSpan).name : 'null'}`); + return "inner-result"; + }; + + const outerFunction = () => { + const activeSpanBefore = core.getActiveSpan(); + executionTrace.push(`outer-start: ${activeSpanBefore ? (activeSpanBefore as IReadableSpan).name : 'null'}`); + + const innerResult = withSpan(core, innerSpan!, innerFunction); + + const activeSpanAfter = core.getActiveSpan(); + executionTrace.push(`outer-end: ${activeSpanAfter ? (activeSpanAfter as IReadableSpan).name : 'null'}`); + + return `outer(${innerResult})`; + }; + + // Act + const result = withSpan(core, outerSpan!, outerFunction); + + // Assert + Assert.equal(result, "outer(inner-result)", "Nested withSpan should work correctly"); + Assert.equal(executionTrace.length, 3, "Should have captured 3 execution points"); + Assert.equal(executionTrace[0], "outer-start: outer-span", "Outer function should see outer span"); + Assert.equal(executionTrace[1], "inner: inner-span", "Inner function should see inner span"); + Assert.equal(executionTrace[2], "outer-end: outer-span", "Outer function should see outer span restored"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "No active span should remain after nested execution"); + } + }); + + this.testCase({ + name: "withSpan: should handle span operations within withSpan context", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const testSpan = core.startSpan("withSpan-test-operations"); + Assert.ok(testSpan, "Test span should be created"); + + const testFunction = () => { + const activeSpan = core.getActiveSpan(); + Assert.ok(activeSpan, "Should have active span in function"); + + // Perform span operations + activeSpan!.setAttribute("operation.name", "test-operation"); + activeSpan!.setAttribute("operation.step", 1); + + // Create child span + const childSpan = core.startSpan("child-operation"); + Assert.ok(childSpan, "Child span should be created"); + + childSpan!.setAttribute("child.attribute", "child-value"); + childSpan!.end(); + + return "operations-completed"; + }; + + // Act + const result = withSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, "operations-completed", "Function should complete successfully"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "Active span should be restored"); + + // Verify span operations were applied (span should still be valid) + const readableSpan = testSpan! as IReadableSpan; + Assert.ok(!readableSpan.ended, "Test span should not be ended"); + Assert.ok(testSpan!.isRecording(), "Test span should still be recording"); + } + }); + + this.testCase({ + name: "withSpan: should work with core that has no trace provider", + test: () => { + // Arrange + const core = new AppInsightsCore(); + + // Create a simple test channel + const testChannel = { + identifier: "TestChannel", + priority: 1001, + initialize: () => {}, + processTelemetry: () => {}, + teardown: () => {}, + isInitialized: () => true + }; + + core.initialize({ instrumentationKey: "test-key" }, [testChannel]); // Initialize with channel but no trace provider + + // Create a mock span (this would need to come from somewhere else since no provider) + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const mockSpan = createSpan(spanCtx, "mock-span", eOTelSpanKind.CLIENT); + + let functionExecuted = false; + const testFunction = () => { + functionExecuted = true; + return "no-provider-result"; + }; + + // Act + const result = withSpan(core, mockSpan, testFunction); + + // Assert + Assert.equal(result, "no-provider-result", "Function should execute even without trace provider"); + Assert.ok(functionExecuted, "Function should have been executed"); + + // Cleanup + core.unload(false); + } + }); + + this.testCase({ + name: "withSpan: performance test - should not add significant overhead", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("withSpan-performance-test"); + Assert.ok(testSpan, "Test span should be created"); + + const iterations = 10000; + let computeResult = 0; + + const computeFunction = (base: number, multiplier: number) => { + // Simple computation to measure overhead + return base * multiplier + Math.sqrt(base); + }; + + // Measure time without withSpan + const startWithout = perfNow(); + for (let i = 0; i < iterations; i++) { + computeResult += computeFunction(i, 2); + } + const timeWithout = perfNow() - startWithout; + + // Reset result + computeResult = 0; + + // Measure time with withSpan + const startWith = perfNow(); + for (let i = 0; i < iterations; i++) { + computeResult += withSpan(core, testSpan!, computeFunction, undefined, i, 2); + } + const timeWith = perfNow() - startWith; + + // Assert reasonable performance characteristics + // withSpan should not add more than 10x overhead (very generous threshold) + const overhead = timeWith / (timeWithout || 1); + Assert.ok(overhead < 15, `withSpan overhead should be reasonable: ${overhead.toFixed(2)}x`); + + // Results should be the same + Assert.ok(computeResult > 0, "Computations should have produced results"); + } + }); + // === useSpan Helper Tests === + + this.testCase({ + name: "useSpan: should execute function with span as active span", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const testSpan = core.startSpan("useSpan-test-active"); + Assert.ok(testSpan, "Test span should be created"); + + let capturedActiveSpan: IReadableSpan | null = null; + const testFunction = () => { + capturedActiveSpan = core.getActiveSpan(); + return "test-result"; + }; + + // Act + const result = useSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, "test-result", "useSpan should return function result"); + Assert.ok(capturedActiveSpan, "Function should have access to active span"); + Assert.equal(capturedActiveSpan, testSpan, "Active span should be the provided span"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "Active span should be restored after execution"); + } + }); + + this.testCase({ + name: "useSpan: should restore previous active span after execution", + test: () => { + // Arrange + const core = this._setupCore(); + const previousSpan = core.startSpan("previous-span"); + const testSpan = core.startSpan("useSpan-test-restore"); + Assert.ok(previousSpan && testSpan, "Both spans should be created"); + + // Set previous span as active + core.setActiveSpan(previousSpan!); + Assert.equal(core.getActiveSpan(), previousSpan, "Previous span should be active initially"); + + let capturedActiveSpan: IReadableSpan | null = null; + const testFunction = () => { + capturedActiveSpan = core.getActiveSpan(); + return 42; + }; + + // Act + const result = useSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, 42, "useSpan should return function result"); + Assert.equal(capturedActiveSpan, testSpan, "Function should have access to test span"); + Assert.equal(core.getActiveSpan(), previousSpan, "Previous active span should be restored"); + } + }); + + this.testCase({ + name: "useSpan: should handle function with arguments", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("useSpan-test-args"); + Assert.ok(testSpan, "Test span should be created"); + + let capturedArgs: any[] = []; + const testFunction = (scope: ISpanScope, ...args: any[]) => { + capturedArgs = args; + return args.reduce((sum, val) => sum + val, 0); + }; + + // Act + const result = useSpan(core, testSpan!, testFunction, undefined, 10, 20, 30); + + // Assert + Assert.equal(result, 60, "useSpan should return correct sum"); + Assert.equal(capturedArgs.length, 3, "Function should receive all arguments"); + Assert.equal(capturedArgs[0], 10, "First argument should be correct"); + Assert.equal(capturedArgs[1], 20, "Second argument should be correct"); + Assert.equal(capturedArgs[2], 30, "Third argument should be correct"); + } + }); + + this.testCase({ + name: "useSpan: should handle function with thisArg context", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("useSpan-test-this"); + Assert.ok(testSpan, "Test span should be created"); + + const contextObject = { + value: 100, + getValue: function(scope: ISpanScope, multiplier: number) { + return this.value * multiplier; + } + }; + + // Act + const result = useSpan(core, testSpan!, contextObject.getValue, contextObject, 2); + + // Assert + Assert.equal(result, 200, "useSpan should execute with correct this context"); + } + }); + + this.testCase({ + name: "useSpan: should handle exceptions and still restore active span", + test: () => { + // Arrange + const core = this._setupCore(); + const previousSpan = core.startSpan("previous-span-exception"); + const testSpan = core.startSpan("useSpan-test-exception"); + Assert.ok(previousSpan && testSpan, "Both spans should be created"); + + core.setActiveSpan(previousSpan!); + + const testFunction = () => { + throw new Error("Test exception"); + }; + + // Act & Assert + let thrownError: Error | null = null; + try { + useSpan(core, testSpan!, testFunction); + } catch (error) { + thrownError = error as Error; + } + + Assert.ok(thrownError, "Exception should be thrown"); + Assert.equal(thrownError!.message, "Test exception", "Exception message should be preserved"); + Assert.equal(core.getActiveSpan(), previousSpan, "Previous active span should be restored even after exception"); + } + }); + + this.testCase({ + name: "useSpan: should work with functions returning different types", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("useSpan-test-types"); + Assert.ok(testSpan, "Test span should be created"); + + // Test string return + const stringResult = useSpan(core, testSpan!, () => "hello world"); + Assert.equal(stringResult, "hello world", "String return should work"); + + // Test number return + const numberResult = useSpan(core, testSpan!, () => 123.45); + Assert.equal(numberResult, 123.45, "Number return should work"); + + // Test boolean return + const booleanResult = useSpan(core, testSpan!, () => true); + Assert.equal(booleanResult, true, "Boolean return should work"); + + // Test object return + const objectResult = useSpan(core, testSpan!, () => ({ key: "value" })); + Assert.ok(objectResult && objectResult.key === "value", "Object return should work"); + + // Test undefined return + const undefinedResult = useSpan(core, testSpan!, () => undefined); + Assert.equal(undefinedResult, undefined, "Undefined return should work"); + + // Test null return + const nullResult = useSpan(core, testSpan!, () => null); + Assert.equal(nullResult, null, "Null return should work"); + } + }); + + this.testCase({ + name: "useSpan: should work with async-like function patterns", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const testSpan = core.startSpan("useSpan-test-async-pattern"); + Assert.ok(testSpan, "Test span should be created"); + + let spanDuringExecution: IReadableSpan | null = null; + + // Simulate async-like pattern with callback + const asyncFunction = (scope: ISpanScope, callback: (result: string) => void) => { + spanDuringExecution = scope.span; + // Simulate some async work completing synchronously for this test + callback("async-result"); + return "function-result"; + }; + + let callbackResult = ""; + const callback = (result: string) => { + callbackResult = result; + }; + + // Act + const result = useSpan(core, testSpan!, asyncFunction, undefined, callback); + + // Assert + Assert.equal(result, "function-result", "useSpan should return main function result"); + Assert.equal(callbackResult, "async-result", "Callback should be executed"); + Assert.equal(spanDuringExecution, testSpan, "Active span should be available during execution"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "Active span should be restored after completion"); + } + }); + + this.testCase({ + name: "useSpan: should work when no previous active span exists", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + const testSpan = core.startSpan("useSpan-test-no-previous"); + Assert.ok(testSpan, "Test span should be created"); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "With a traceprovider, activeSpan should not be null"); + + let capturedActiveSpan: IReadableSpan | null = null; + const testFunction = () => { + capturedActiveSpan = core.getActiveSpan(); + return "success"; + }; + + // Act + const result = useSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, "success", "Function should execute successfully"); + Assert.equal(capturedActiveSpan, testSpan, "Test span should be active during execution"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "No active span should be restored (was null)"); + } + }); + + this.testCase({ + name: "useSpan: should work with nested useSpan calls", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const outerSpan = core.startSpan("outer-span"); + const innerSpan = core.startSpan("inner-span"); + Assert.ok(outerSpan && innerSpan, "Both spans should be created"); + + const executionTrace: string[] = []; + + const innerFunction = (scope: ISpanScope) => { + const activeSpan = scope.span; + executionTrace.push(`inner: ${activeSpan ? (activeSpan as IReadableSpan).name : 'null'}`); + return "inner-result"; + }; + + const outerFunction = (scope: ISpanScope) => { + const activeSpanBefore = scope.span; + executionTrace.push(`outer-start: ${activeSpanBefore ? (activeSpanBefore as IReadableSpan).name : 'null'}`); + + const innerResult = useSpan(core, innerSpan!, innerFunction); + + const activeSpanAfter = core.getActiveSpan(); + executionTrace.push(`outer-end: ${activeSpanAfter ? (activeSpanAfter as IReadableSpan).name : 'null'}`); + + return `outer(${innerResult})`; + }; + + // Act + const result = useSpan(core, outerSpan!, outerFunction); + + // Assert + Assert.equal(result, "outer(inner-result)", "Nested useSpan should work correctly"); + Assert.equal(executionTrace.length, 3, "Should have captured 3 execution points"); + Assert.equal(executionTrace[0], "outer-start: outer-span", "Outer function should see outer span"); + Assert.equal(executionTrace[1], "inner: inner-span", "Inner function should see inner span"); + Assert.equal(executionTrace[2], "outer-end: outer-span", "Outer function should see outer span restored"); + Assert.equal(core.getActiveSpan?.(), initialActiveSpan, "The initial active span should be restored after nested execution"); + } + }); + + this.testCase({ + name: "useSpan: should handle span operations within useSpan context", + test: () => { + // Arrange + const core = this._setupCore(); + let initialActiveSpan = core.getActiveSpan(); + Assert.ok(!isNullOrUndefined(initialActiveSpan), "Initially, activeSpan should not be null with a trace provider"); + const testSpan = core.startSpan("useSpan-test-operations"); + Assert.ok(testSpan, "Test span should be created"); + + const testFunction = (scope: ISpanScope) => { + const activeSpan = scope.span; + Assert.ok(activeSpan, "Should have active span in function"); + + // Perform span operations + activeSpan.setAttribute("operation.name", "test-operation"); + activeSpan.setAttribute("operation.step", 1); + + // Create child span + const childSpan = core.startSpan("child-operation"); + Assert.ok(childSpan, "Child span should be created"); + + childSpan?.setAttribute("child.attribute", "child-value"); + childSpan?.end(); + + return "operations-completed"; + }; + + // Act + const result = useSpan(core, testSpan!, testFunction); + + // Assert + Assert.equal(result, "operations-completed", "Function should complete successfully"); + Assert.equal(core.getActiveSpan(), initialActiveSpan, "Active span should be restored"); + + // Verify span operations were applied (span should still be valid) + const readableSpan = testSpan! as IReadableSpan; + Assert.ok(!readableSpan.ended, "Test span should not be ended"); + Assert.ok(testSpan!.isRecording(), "Test span should still be recording"); + } + }); + + this.testCase({ + name: "useSpan: should work with core that has no trace provider", + test: () => { + // Arrange + const core = new AppInsightsCore(); + + // Create a simple test channel + const testChannel = { + identifier: "TestChannel", + priority: 1001, + initialize: () => {}, + processTelemetry: () => {}, + teardown: () => {}, + isInitialized: () => true + }; + + core.initialize({ instrumentationKey: "test-key" }, [testChannel]); // Initialize with channel but no trace provider + + // Create a mock span (this would need to come from somewhere else since no provider) + const spanCtx: IOTelSpanCtx = { + api: this._mockApi, + spanContext: this._mockSpanContext, + onEnd: (span) => this._onEndCalls.push(span) + }; + const mockSpan = createSpan(spanCtx, "mock-span", eOTelSpanKind.CLIENT); + + let functionExecuted = false; + const testFunction = () => { + functionExecuted = true; + return "no-provider-result"; + }; + + // Act + const result = useSpan(core, mockSpan, testFunction); + + // Assert + Assert.equal(result, "no-provider-result", "Function should execute even without trace provider"); + Assert.ok(functionExecuted, "Function should have been executed"); + + // Cleanup + core.unload(false); + } + }); + + this.testCase({ + name: "useSpan: performance test - should not add significant overhead", + test: () => { + // Arrange + const core = this._setupCore(); + const testSpan = core.startSpan("useSpan-performance-test"); + Assert.ok(testSpan, "Test span should be created"); + + const iterations = 10000; + let computeResult = 0; + + const computeFunction = (_scope: ISpanScope, base: number, multiplier: number) => { + // Simple computation to measure overhead + return base * multiplier + Math.sqrt(base); + }; + + let maxOverhead: number = 100; + + // Perform multiple runs to get a stable measurement + for (let lp = 0; lp < 10; lp++) { + // Measure time without useSpan + const startWithout = perfNow(); + for (let i = 0; i < iterations; i++) { + computeResult += computeFunction(null as any as ISpanScope, i, 2); + } + const timeWithout = perfNow() - startWithout; + + // Reset result + computeResult = 0; + + // Measure time with useSpan + const startWith = perfNow(); + for (let i = 0; i < iterations; i++) { + computeResult += useSpan(core, testSpan!, computeFunction, undefined, i, 2); + } + + // Results should be the same + Assert.ok(computeResult > 0, "Computations should have produced results"); + + const timeWith = perfNow() - startWith; + + const overhead = timeWith / (timeWithout || 1); + + if (lp === 0) { + maxOverhead = overhead; + } + maxOverhead = mathMin(maxOverhead, overhead); + } + + // Assert reasonable performance characteristics + // useSpan should not add more than 10x overhead (very generous threshold) + Assert.ok(maxOverhead < 10, `useSpan overhead should be reasonable: ${maxOverhead.toFixed(2)}x`); + + } + }); + } +} \ No newline at end of file diff --git a/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/traceUtils.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/traceUtils.Tests.ts new file mode 100644 index 000000000..79f7faacf --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/traceUtils.Tests.ts @@ -0,0 +1,1355 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { isSpanContextValid, wrapSpanContext, createNonRecordingSpan, isReadableSpan, useSpan, withSpan } from "../../../../src/OpenTelemetry/trace/utils"; +import { createTraceProvider } from "../../../../src/OpenTelemetry/trace/traceProvider"; +import { IDistributedTraceContext } from "../../../../src/JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { createPromise, createRejectedPromise, doAwait } from "@nevware21/ts-async"; +import { createCachedValue, isNullOrUndefined } from "@nevware21/ts-utils"; +import { IOTelApi } from "../../../../src/OpenTelemetry/interfaces/IOTelApi"; +import { createOTelApi } from "../../../../src/OpenTelemetry/otelApi"; +import { eOTelSpanKind } from "../../../../src/OpenTelemetry/enums/trace/OTelSpanKind"; +import { IOTelSpanCtx } from "../../../../src/OpenTelemetry/interfaces/trace/IOTelSpanCtx"; +import { createSpan } from "../../../../src/OpenTelemetry/trace/span"; +import { createW3cTraceState } from "../../../../src/JavaScriptSDK/W3cTraceState"; +import { createDistributedTraceContext } from "../../../../src/JavaScriptSDK/TelemetryHelpers"; +import { IAppInsightsCore } from "../../../../src/JavaScriptSDK.Interfaces/IAppInsightsCore"; +import { AppInsightsCore, ITraceHost } from "../../../../src/applicationinsights-core-js"; +import { IChannelControls } from "../../../../src/JavaScriptSDK.Interfaces/IChannelControls"; + +function _createDistributedContext(traceId: string, spanId: string, traceFlags: number, traceState?: string): IDistributedTraceContext { + const theContext: IDistributedTraceContext = { + traceId: traceId, + spanId: spanId, + traceFlags: traceFlags, + getName: function (): string { + throw new Error("Function not implemented."); + }, + setName: function (pageName: string): void { + throw new Error("Function not implemented."); + }, + getTraceId: function (): string { + throw new Error("Function not implemented."); + }, + setTraceId: function (newValue: string): void { + throw new Error("Function not implemented."); + }, + getSpanId: function (): string { + throw new Error("Function not implemented."); + }, + setSpanId: function (newValue: string): void { + throw new Error("Function not implemented."); + }, + getTraceFlags: function (): number | undefined { + throw new Error("Function not implemented."); + }, + setTraceFlags: function (newValue?: number): void { + throw new Error("Function not implemented."); + }, + pageName: "", + isRemote: false, + traceState: traceState ? createW3cTraceState(traceState) : createW3cTraceState() + }; + + return theContext; +} + +export class TraceUtilsTests extends AITestClass { + private _core: IAppInsightsCore = null as any; + private _otelApi!: IOTelApi; + private _validSpanContext!: IDistributedTraceContext; + + public testInitialize() { + // Create a minimal mock channel to satisfy core initialization requirements + const mockChannel: IChannelControls = { + pause: () => {}, + resume: () => {}, + flush: () => {}, + teardown: () => {}, + processTelemetry: () => {}, + initialize: () => {}, + identifier: "mockChannel", + priority: 1001 + } as any; + + this._core = new AppInsightsCore(); + this._core.initialize({ + instrumentationKey: "00000000-0000-0000-0000-000000000000", + disableInstrumentationKeyValidation: true, + traceCfg: {}, + errorHandlers: { + attribError: (message: string, key: string, value: any) => { + console.error(message); + }, + spanError: (message: string, spanName: string) => { + console.error(message); + }, + debug: (message: string) => { + console.error(message); + }, + warn: (message: string) => { + console.error(message); + }, + error: (message: string) => { + console.error(message); + }, + notImplemented: (message: string) => { + console.error(message); + } + } + }, [mockChannel]); + + this._otelApi = createOTelApi({ host: this._core }); + + // Set up a simple trace provider for the core + this._setupTraceProvider(); + + // Valid span context with proper IDs + this._validSpanContext = _createDistributedContext("12345678901234567890123456789012", "1234567890123456", 1); + } + + private _setupTraceProvider(): void { + // Using an cached value to wrap the provider, so it is created immediately + const provider = createCachedValue(createTraceProvider( + this._core, + "test-provider", + this._otelApi + )); + + this._core.setTraceProvider(provider); + } + + public testCleanup() { + if (this._core) { + this._core.unload(false); + this._core = null as any; + } + this._otelApi = null as any; + this._validSpanContext = null as any; + } + + public registerTests() { + this.addIsSpanContextValidTests(); + this.addWrapSpanContextTests(); + this.addCreateNonRecordingSpanTests(); + this.addIsReadableSpanTests(); + this.addWithSpanTests(); + this.addUseSpanTests(); + } + + private addIsSpanContextValidTests(): void { + this.testCase({ + name: "isSpanContextValid: should return true for valid span context", + test: () => { + // Act + const result = isSpanContextValid(this._validSpanContext); + + // Assert + Assert.ok(result, "Should return true for valid span context"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for invalid trace ID", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("invalid-trace-id", "1234567890123456", 1); + + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for invalid trace ID"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for invalid span ID", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("12345678901234567890123456789012", "bad", 1); + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for invalid span ID"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for null context", + test: () => { + // Act + const result = isSpanContextValid(null as any); + + // Assert + Assert.ok(!result, "Should return false for null context"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for undefined context", + test: () => { + // Act + const result = isSpanContextValid(undefined as any); + + // Assert + Assert.ok(!result, "Should return false for undefined context"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for empty trace ID", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("", "1234567890123456", 1); + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for empty trace ID"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for empty span ID", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("12345678901234567890123456789012", "", 1); + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for empty span ID"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for all-zero trace ID", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("00000000000000000000000000000000", "1234567890123456", 1); + + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for all-zero trace ID"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for all-zero span ID", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("12345678901234567890123456789012", "0000000000000000", 1); + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for all-zero span ID"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for trace ID with wrong length", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("123456789012", "1234567890123456", 1); + + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for trace ID with wrong length"); + } + }); + + this.testCase({ + name: "isSpanContextValid: should return false for span ID with wrong length", + test: () => { + // Arrange + const invalidContext: IDistributedTraceContext = _createDistributedContext("12345678901234567890123456789012", "1234", 1); + // Act + const result = isSpanContextValid(invalidContext); + + // Assert + Assert.ok(!result, "Should return false for span ID with wrong length"); + } + }); + } + + private addWrapSpanContextTests(): void { + this.testCase({ + name: "wrapSpanContext: should create non-recording span from valid context", + test: () => { + // Act + const wrappedSpan = wrapSpanContext(this._otelApi, this._validSpanContext); + + // Assert + Assert.ok(wrappedSpan, "Should create a span"); + Assert.ok(!wrappedSpan.isRecording(), "Wrapped span should not be recording"); + Assert.equal(wrappedSpan.spanContext().traceId, this._validSpanContext.traceId, "Trace ID should match"); + Assert.equal(wrappedSpan.spanContext().spanId, this._validSpanContext.spanId, "Span ID should match"); + } + }); + + this.testCase({ + name: "wrapSpanContext: should include span ID in wrapped span name", + test: () => { + // Act + const wrappedSpan = wrapSpanContext(this._otelApi, this._validSpanContext); + + // Assert + Assert.ok(wrappedSpan.name.includes(this._validSpanContext.spanId), + "Span name should include original span ID"); + Assert.ok(wrappedSpan.name.includes("wrapped"), + "Span name should indicate it's a wrapped span"); + } + }); + + this.testCase({ + name: "wrapSpanContext: should preserve trace flags", + test: () => { + // Arrange + const contextWithFlags: IDistributedTraceContext = _createDistributedContext("12345678901234567890123456789012", "1234567890123456", 1); + + // Act + const wrappedSpan = wrapSpanContext(this._otelApi, contextWithFlags); + + // Assert + Assert.equal(wrappedSpan.spanContext().traceFlags, contextWithFlags.traceFlags, + "Trace flags should be preserved"); + } + }); + + this.testCase({ + name: "wrapSpanContext: should handle context with tracestate", + test: () => { + // Arrange + let contextWithState: IDistributedTraceContext = createDistributedTraceContext(); + contextWithState.traceId = "12345678901234567890123456789012"; + contextWithState.spanId = "1234567890123456"; + contextWithState.traceFlags = 1; + contextWithState.traceState.set("vendor", "value"); + + // Act + const wrappedSpan = wrapSpanContext(this._otelApi, contextWithState); + + // Assert + Assert.ok(wrappedSpan, "Should create span with tracestate"); + Assert.equal(wrappedSpan.spanContext().traceState, contextWithState.traceState, + "Trace state should be preserved if present"); + } + }); + + this.testCase({ + name: "wrapSpanContext: wrapped span should not be ended", + test: () => { + // Act + const wrappedSpan = wrapSpanContext(this._otelApi, this._validSpanContext); + + // Assert + Assert.ok(!wrappedSpan.ended, "Wrapped span should not be ended initially"); + } + }); + + this.testCase({ + name: "wrapSpanContext: wrapped span should be internal kind", + test: () => { + // Act + const wrappedSpan = wrapSpanContext(this._otelApi, this._validSpanContext); + + // Assert + Assert.equal(wrappedSpan.kind, eOTelSpanKind.INTERNAL, + "Wrapped span should have INTERNAL kind"); + } + }); + } + + private addCreateNonRecordingSpanTests(): void { + this.testCase({ + name: "createNonRecordingSpan: should create non-recording span", + test: () => { + // Arrange + const spanName = "test-non-recording"; + + // Act + const span = createNonRecordingSpan(this._otelApi, spanName, this._validSpanContext); + + // Assert + Assert.ok(span, "Should create a span"); + Assert.ok(!span.isRecording(), "Span should not be recording"); + Assert.equal(span.name, spanName, "Span name should match"); + } + }); + + this.testCase({ + name: "createNonRecordingSpan: should use provided span context", + test: () => { + // Arrange + const spanName = "context-test"; + + // Act + const span = createNonRecordingSpan(this._otelApi, spanName, this._validSpanContext); + + // Assert + Assert.equal(span.spanContext().traceId, this._validSpanContext.traceId, + "Trace ID should match provided context"); + Assert.equal(span.spanContext().spanId, this._validSpanContext.spanId, + "Span ID should match provided context"); + Assert.equal(span.spanContext().traceFlags, this._validSpanContext.traceFlags, + "Trace flags should match provided context"); + } + }); + + this.testCase({ + name: "createNonRecordingSpan: should create span with INTERNAL kind", + test: () => { + // Act + const span = createNonRecordingSpan(this._otelApi, "test", this._validSpanContext); + + // Assert + Assert.equal(span.kind, eOTelSpanKind.INTERNAL, + "Non-recording span should have INTERNAL kind"); + } + }); + + this.testCase({ + name: "createRecordingSpan: recording span with onEnd should still trigger callback", + test: () => { + // Arrange + let onEndCalled = false; + + // Create a non-recording span directly with createSpan to test onEnd callback behavior + const spanCtx: IOTelSpanCtx = { + api: this._otelApi, + spanContext: this._validSpanContext, + onEnd: () => { + onEndCalled = true; + } + }; + + // Act - Create span directly with our custom context that includes onEnd callback + const span = createSpan(spanCtx, "test-recording-with-callback", eOTelSpanKind.INTERNAL); + Assert.ok(span.isRecording(), "Span should be recording"); + span.end(); + + // Assert + // onEnd callbacks are called regardless of recording state when the callback is provided + Assert.ok(onEndCalled, "onEnd callback should be called even for non-recording spans when callback is registered"); + } + }); + + this.testCase({ + name: "createNonRecordingSpan: non-recording span with onEnd should still trigger callback", + test: () => { + // Arrange + let onEndCalled = false; + + // Create a non-recording span directly with createSpan to test onEnd callback behavior + const spanCtx: IOTelSpanCtx = { + api: this._otelApi, + spanContext: this._validSpanContext, + isRecording: false, // Non-recording span + onEnd: () => { + onEndCalled = true; + } + }; + + // Act - Create span directly with our custom context that includes onEnd callback + const span = createSpan(spanCtx, "test-non-recording-with-callback", eOTelSpanKind.INTERNAL); + Assert.ok(!span.isRecording(), "Span should not be recording"); + span.end(); + + // Assert + // onEnd callbacks are called regardless of recording state when the callback is provided + // Allows for post-end processing even for non-recording spans, including tracking the number of non-recording spans ended + Assert.ok(onEndCalled, "onEnd callback should be called even for non-recording spans when callback is registered"); + } + }); + + this.testCase({ + name: "createNonRecordingSpan: utility function creates span without onEnd callback", + test: () => { + // Arrange & Act + // createNonRecordingSpan is a utility that doesn't accept an onEnd callback parameter + const span = createNonRecordingSpan(this._otelApi, "test-non-recording", this._validSpanContext); + + // Assert + Assert.ok(!span.isRecording(), "Span should not be recording"); + // This just verifies the utility function works as expected - no onEnd callback to test + + span.end(); + + // Validate that calling after end does not cause issues + Assert.ok(!span.isRecording(), "Span should not be recording"); + } + }); + + this.testCase({ + name: "createNonRecordingSpan: should accept custom span names", + test: () => { + // Arrange + const customNames = [ + "operation-1", + "http-request", + "database-query", + "cache-lookup", + "" + ]; + + // Act & Assert + customNames.forEach(name => { + const span = createNonRecordingSpan(this._otelApi, name, this._validSpanContext); + Assert.equal(span.name, name, `Span name should be '${name}'`); + Assert.ok(!span.isRecording(), "All non-recording spans should not be recording"); + }); + } + }); + + this.testCase({ + name: "createNonRecordingSpan: should preserve tracestate from context", + test: () => { + // Arrange + const contextWithState: IDistributedTraceContext = _createDistributedContext("12345678901234567890123456789012", "1234567890123456", 1, "vendor1=value1,vendor2=value2"); + + // Act + const span = createNonRecordingSpan(this._otelApi, "test", contextWithState); + + // Assert + Assert.equal(span.spanContext().traceState, contextWithState.traceState, + "Trace state should be preserved"); + } + }); + } + + private addIsReadableSpanTests(): void { + this.testCase({ + name: "isReadableSpan: should return true for valid span", + test: () => { + // Arrange - create a real span + const span = createNonRecordingSpan(this._otelApi, "test", this._validSpanContext); + + // Act + const result = isReadableSpan(span); + + // Assert + Assert.ok(result, "Should return true for a valid readable span"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for null", + test: () => { + // Act + const result = isReadableSpan(null); + + // Assert + Assert.ok(!result, "Should return false for null"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for undefined", + test: () => { + // Act + const result = isReadableSpan(undefined); + + // Assert + Assert.ok(!result, "Should return false for undefined"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for empty object", + test: () => { + // Act + const result = isReadableSpan({}); + + // Assert + Assert.ok(!result, "Should return false for empty object"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for object with only some properties", + test: () => { + // Arrange + const partialSpan = { + name: "test", + kind: eOTelSpanKind.CLIENT + }; + + // Act + const result = isReadableSpan(partialSpan); + + // Assert + Assert.ok(!result, "Should return false for object missing required properties"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for object missing spanContext method", + test: () => { + // Arrange + const invalidSpan = { + name: "test", + kind: eOTelSpanKind.CLIENT, + duration: [0, 0], + ended: false, + startTime: [0, 0], + endTime: [0, 0], + attributes: {}, + links: [], + events: [], + status: { code: 0 }, + resource: {}, + instrumentationScope: {}, + droppedAttributesCount: 0, + isRecording: () => false, + setStatus: () => {}, + updateName: () => {}, + setAttribute: () => {}, + setAttributes: () => {}, + end: () => {}, + recordException: () => {} + // Missing spanContext method + }; + + // Act + const result = isReadableSpan(invalidSpan); + + // Assert + Assert.ok(!result, "Should return false when spanContext method is missing"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for object with non-function methods", + test: () => { + // Arrange + const invalidSpan = { + name: "test", + kind: eOTelSpanKind.CLIENT, + spanContext: "not a function", + duration: [0, 0], + ended: false, + startTime: [0, 0], + endTime: [0, 0], + attributes: {}, + links: [], + events: [], + status: { code: 0 }, + resource: {}, + instrumentationScope: {}, + droppedAttributesCount: 0, + isRecording: () => false, + setStatus: () => {}, + updateName: () => {}, + setAttribute: () => {}, + setAttributes: () => {}, + end: () => {}, + recordException: () => {} + }; + + // Act + const result = isReadableSpan(invalidSpan); + + // Assert + Assert.ok(!result, "Should return false when required methods are not functions"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for primitive values", + test: () => { + // Act & Assert + Assert.ok(!isReadableSpan("string"), "Should return false for string"); + Assert.ok(!isReadableSpan(123), "Should return false for number"); + Assert.ok(!isReadableSpan(true), "Should return false for boolean"); + } + }); + + this.testCase({ + name: "isReadableSpan: should return false for array", + test: () => { + // Act + const result = isReadableSpan([]); + + // Assert + Assert.ok(!result, "Should return false for array"); + } + }); + + this.testCase({ + name: "isReadableSpan: should validate all required properties exist", + test: () => { + // Arrange - create a valid span to ensure our check is comprehensive + const span = createNonRecordingSpan(this._otelApi, "validation-test", this._validSpanContext); + + // Act - verify the span has all required properties + const hasName = "name" in span; + const hasKind = "kind" in span; + const hasSpanContext = typeof span.spanContext === "function"; + const hasDuration = "duration" in span; + const hasEnded = "ended" in span; + const hasStartTime = "startTime" in span; + const hasEndTime = "endTime" in span; + const hasAttributes = "attributes" in span; + const hasLinks = "links" in span; + const hasEvents = "events" in span; + const hasStatus = "status" in span; + // const hasResource = "resource" in span; + // const hasInstrumentationScope = "instrumentationScope" in span; + const hasDroppedAttributesCount = "droppedAttributesCount" in span; + const hasIsRecording = typeof span.isRecording === "function"; + const hasSetStatus = typeof span.setStatus === "function"; + const hasUpdateName = typeof span.updateName === "function"; + const hasSetAttribute = typeof span.setAttribute === "function"; + const hasSetAttributes = typeof span.setAttributes === "function"; + const hasEnd = typeof span.end === "function"; + const hasRecordException = typeof span.recordException === "function"; + + // Assert + Assert.ok(hasName, "Should have name property"); + Assert.ok(hasKind, "Should have kind property"); + Assert.ok(hasSpanContext, "Should have spanContext method"); + Assert.ok(hasDuration, "Should have duration property"); + Assert.ok(hasEnded, "Should have ended property"); + Assert.ok(hasStartTime, "Should have startTime property"); + Assert.ok(hasEndTime, "Should have endTime property"); + Assert.ok(hasAttributes, "Should have attributes property"); + Assert.ok(hasLinks, "Should have links property"); + Assert.ok(hasEvents, "Should have events property"); + Assert.ok(hasStatus, "Should have status property"); + // Assert.ok(hasResource, "Should have resource property"); + // Assert.ok(hasInstrumentationScope, "Should have instrumentationScope property"); + Assert.ok(hasDroppedAttributesCount, "Should have droppedAttributesCount property"); + Assert.ok(hasIsRecording, "Should have isRecording method"); + Assert.ok(hasSetStatus, "Should have setStatus method"); + Assert.ok(hasUpdateName, "Should have updateName method"); + Assert.ok(hasSetAttribute, "Should have setAttribute method"); + Assert.ok(hasSetAttributes, "Should have setAttributes method"); + Assert.ok(hasEnd, "Should have end method"); + Assert.ok(hasRecordException, "Should have recordException method"); + + // Final validation + Assert.ok(isReadableSpan(span), "isReadableSpan should return true for complete span"); + } + }); + + this.testCase({ + name: "isReadableSpan: should work with recording spans", + test: () => { + // Arrange - this would be a recording span in real usage + const recordingSpan = createNonRecordingSpan(this._otelApi, "recording-test", this._validSpanContext); + + // Act + const result = isReadableSpan(recordingSpan); + + // Assert + Assert.ok(result, "Should return true for both recording and non-recording spans"); + } + }); + } + + private addWithSpanTests(): void { + this.testCase({ + name: "withSpan: should execute synchronous callback and return value", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-sync", this._validSpanContext); + const expectedValue = "sync-result"; + + // Act + const result = withSpan(this._core, span, function() { + return expectedValue; + }); + + // Assert + Assert.equal(result, expectedValue, "Should return the callback result"); + } + }); + + this.testCase({ + name: "withSpan: should set and restore active span for synchronous callback", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(this._otelApi, "test-active-span", this._validSpanContext); + let activeSpanDuringCallback: any = null; + let coreActiveSpanDuringCallback: any = null; + const originalActiveSpan = this._core.getActiveSpan(); + + Assert.ok(!isNullOrUndefined(originalActiveSpan), "Original active span should not be null or undefined"); + + // Act + withSpan(this._core, span, function() { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + coreActiveSpanDuringCallback = _self._core.getActiveSpan(); + + Assert.equal(coreActiveSpanDuringCallback, activeSpanDuringCallback, "Core active span and OTEL active span should match during callback"); + }); + + const activeSpanAfterCallback = this._otelApi.trace.getActiveSpan(); + const coreActiveSpanAfterCallback = this._core.getActiveSpan(); + + Assert.equal(coreActiveSpanAfterCallback, activeSpanAfterCallback, "Core active span and OTEL active span should match after callback"); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + Assert.equal(activeSpanAfterCallback, originalActiveSpan, "Active span should be restored after callback"); + } + }); + + this.testCase({ + name: "withSpan: should handle promise that resolves", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-promise-resolve", _self._validSpanContext); + const expectedValue = "resolved-value"; + let activeSpanDuringCallback: any = null; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = withSpan(_self._core, span, function() { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + return createPromise((resolve) => { + setTimeout(() => resolve(expectedValue), 10); + }); + }, _self); + + const activeSpanAfterCallback = this._otelApi.trace.getActiveSpan(); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + + // Assert active span immediately after callback + Assert.equal(activeSpanAfterCallback, span, "Active span should still be set after callback until promise resolves"); + + return doAwait(promise, (result) => { + Assert.equal(result, expectedValue, "Should resolve with the expected value"); + const activeSpanAfterResolve = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterResolve, originalActiveSpan, "Active span should be restored after promise resolves"); + }); + } + }); + + this.testCase({ + name: "withSpan: should handle promise that rejects", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-promise-reject", _self._validSpanContext); + const expectedError = new Error("test-error"); + let activeSpanDuringCallback: any = null; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = withSpan(_self._core, span, function() { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + return createRejectedPromise(expectedError); + }, _self); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + + return doAwait(promise, + () => { + Assert.ok(false, "Promise should have rejected"); + }, + (error) => { + Assert.equal(error, expectedError, "Should reject with the expected error"); + const activeSpanAfterReject = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterReject, originalActiveSpan, "Active span should be restored after promise rejects"); + } + ); + } + }); + + this.testCase({ + name: "withSpan: should handle async/await pattern with resolved promise", + useFakeTimers: true, + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-async-await", _self._validSpanContext); + const expectedValue = 42; + let activeSpanDuringCallback: any = null; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = withSpan(_self._core, span, function() { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + return createPromise((resolve) => { + setTimeout(() => resolve(expectedValue), 100); + }); + }, _self); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + + // Advance timers to trigger promise resolution + this.clock.tick(100); + + return doAwait(promise, (result) => { + Assert.equal(result, expectedValue, "Should resolve with the expected value"); + const activeSpanAfterResolve = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterResolve, originalActiveSpan, "Active span should be restored after async operation"); + }); + } + }); + + this.testCase({ + name: "withSpan: should handle async/await pattern with rejected promise", + useFakeTimers: true, + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-async-reject", this._validSpanContext); + const expectedError = new Error("async-error"); + const originalActiveSpan = this._otelApi.trace.getActiveSpan(); + + // Act + const promise = withSpan(this._core, span, function() { + return createPromise((resolve, reject) => { + setTimeout(() => reject(expectedError), 50); + }); + }); + + // Advance timers + this.clock.tick(50); + + return doAwait(promise, + () => { + Assert.ok(false, "Promise should have rejected"); + }, + (error) => { + Assert.equal(error, expectedError, "Should reject with the expected error"); + const activeSpanAfterReject = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterReject, originalActiveSpan, "Active span should be restored after rejection"); + } + ); + } + }); + + this.testCase({ + name: "withSpan: should pass arguments to callback", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-args", this._validSpanContext); + const arg1 = "hello"; + const arg2 = 123; + let receivedArgs: any[] = []; + + // Act + withSpan(this._core, span, function(...args: any[]) { + receivedArgs = args; + }, undefined, arg1, arg2); + + // Assert + Assert.equal(receivedArgs.length, 2, "Should receive both arguments"); + Assert.equal(receivedArgs[0], arg1, "First argument should match"); + Assert.equal(receivedArgs[1], arg2, "Second argument should match"); + } + }); + + this.testCase({ + name: "withSpan: should use provided thisArg", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-this", this._validSpanContext); + const thisArg = { testProperty: "test-value" }; + let capturedThis: any = null; + + // Act + withSpan(this._core, span, function() { + capturedThis = this; + }, thisArg); + + // Assert + Assert.equal(capturedThis, thisArg, "Should use provided thisArg"); + Assert.equal(capturedThis.testProperty, "test-value", "thisArg properties should be accessible"); + } + }); + + this.testCase({ + name: "withSpan: should handle synchronous exception", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-exception", this._validSpanContext); + const expectedError = new Error("sync-exception"); + const originalActiveSpan = this._otelApi.trace.getActiveSpan(); + + // Act & Assert + try { + withSpan(this._core, span, function() { + throw expectedError; + }); + Assert.ok(false, "Should have thrown an exception"); + } catch (error) { + Assert.equal(error, expectedError, "Should throw the expected error"); + const activeSpanAfterException = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterException, originalActiveSpan, "Active span should be restored after exception"); + } + } + }); + } + + private addUseSpanTests(): void { + this.testCase({ + name: "useSpan: should execute synchronous callback and return value", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-sync", this._validSpanContext); + const expectedValue = "sync-result"; + + // Act + const result = useSpan(this._core, span, function(scope) { + return expectedValue; + }); + + // Assert + Assert.equal(result, expectedValue, "Should return the callback result"); + } + }); + + this.testCase({ + name: "useSpan: should provide scope as first parameter", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-scope", this._validSpanContext); + let capturedScope: any = null; + + // Act + useSpan(this._core, span, function(scope) { + capturedScope = scope; + }); + + // Assert + Assert.ok(capturedScope, "Should provide scope"); + Assert.ok(typeof capturedScope.restore === "function", "Scope should have restore method"); + } + }); + + this.testCase({ + name: "useSpan: should set and restore active span for synchronous callback", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-active-span", this._validSpanContext); + let activeSpanDuringCallback: any = null; + const _self = this; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + useSpan(this._core, span, function(scope) { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + }, this); + + const activeSpanAfterCallback = this._otelApi.trace.getActiveSpan(); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + Assert.equal(activeSpanAfterCallback, originalActiveSpan, "Active span should be restored after callback"); + } + }); + + this.testCase({ + name: "useSpan: should handle promise that resolves", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-promise-resolve", this._validSpanContext); + const expectedValue = "resolved-value"; + let activeSpanDuringCallback: any = null; + let scopeFromCallback: any = null; + const _self = this; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = useSpan(this._core, span, function(scope) { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + scopeFromCallback = scope; + return createPromise((resolve) => { + setTimeout(() => resolve(expectedValue), 10); + }); + }, this); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + Assert.ok(scopeFromCallback, "Scope should be provided to callback"); + + return doAwait(promise, (result) => { + Assert.equal(result, expectedValue, "Should resolve with the expected value"); + const activeSpanAfterResolve = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterResolve, originalActiveSpan, "Active span should be restored after promise resolves"); + }); + } + }); + + this.testCase({ + name: "useSpan: should handle promise that rejects", + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-promise-reject", this._validSpanContext); + const expectedError = new Error("test-error"); + let activeSpanDuringCallback: any = null; + const _self = this; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = useSpan(this._core, span, function(scope) { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + return createRejectedPromise(expectedError); + }, this); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + + return doAwait(promise, + () => { + Assert.ok(false, "Promise should have rejected"); + }, + (error) => { + Assert.equal(error, expectedError, "Should reject with the expected error"); + const activeSpanAfterReject = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterReject, originalActiveSpan, "Active span should be restored after promise rejects"); + } + ); + } + }); + + this.testCase({ + name: "useSpan: should handle async/await pattern with resolved promise", + useFakeTimers: true, + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-async-await", this._validSpanContext); + const expectedValue = 42; + let activeSpanDuringCallback: any = null; + const _self = this; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = useSpan(this._core, span, function(scope) { + activeSpanDuringCallback = _self._otelApi.trace.getActiveSpan(); + return createPromise((resolve) => { + setTimeout(() => resolve(expectedValue), 100); + }); + }, this); + + // Assert + Assert.equal(activeSpanDuringCallback, span, "Active span should be set during callback"); + + // Advance timers to trigger promise resolution + this.clock.tick(100); + + return doAwait(promise, (result) => { + Assert.equal(result, expectedValue, "Should resolve with the expected value"); + const activeSpanAfterResolve = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterResolve, originalActiveSpan, "Active span should be restored after async operation"); + }); + } + }); + + this.testCase({ + name: "useSpan: should handle async/await pattern with rejected promise", + useFakeTimers: true, + test: () => { + // Arrange + const span = createNonRecordingSpan(this._otelApi, "test-async-reject", this._validSpanContext); + const expectedError = new Error("async-error"); + const _self = this; + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = useSpan(this._core, span, function(scope) { + return createPromise((resolve, reject) => { + setTimeout(() => reject(expectedError), 50); + }); + }); + + // Advance timers + this.clock.tick(50); + + return doAwait(promise, + () => { + Assert.ok(false, "Promise should have rejected"); + }, + (error) => { + Assert.equal(error, expectedError, "Should reject with the expected error"); + const activeSpanAfterReject = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterReject, originalActiveSpan, "Active span should be restored after rejection"); + } + ); + } + }); + + this.testCase({ + name: "useSpan: should pass additional arguments to callback", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-args", _self._validSpanContext); + const arg1 = "hello"; + const arg2 = 123; + let receivedScope: any = null; + let receivedArgs: any[] = []; + + // Act + useSpan(this._core, span, function(scope, ...args: any[]) { + receivedScope = scope; + receivedArgs = args; + }, undefined, arg1, arg2); + + // Assert + Assert.ok(receivedScope, "Should receive scope as first argument"); + Assert.equal(receivedArgs.length, 2, "Should receive additional arguments"); + Assert.equal(receivedArgs[0], arg1, "First additional argument should match"); + Assert.equal(receivedArgs[1], arg2, "Second additional argument should match"); + } + }); + + this.testCase({ + name: "useSpan: should use provided thisArg", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-this", _self._validSpanContext); + const thisArg = { testProperty: "test-value" }; + let capturedThis: any = null; + + // Act + useSpan(this._core, span, function(scope) { + capturedThis = this; + }, thisArg); + + // Assert + Assert.equal(capturedThis, thisArg, "Should use provided thisArg"); + Assert.equal(capturedThis.testProperty, "test-value", "thisArg properties should be accessible"); + } + }); + + this.testCase({ + name: "useSpan: should use scope as thisArg when not provided", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-default-this", _self._validSpanContext); + let capturedThis: any = null; + let capturedScope: any = null; + + // Act + useSpan(_self._core, span, function(scope) { + capturedThis = this; + capturedScope = scope; + }); + + // Assert + Assert.equal(capturedThis, capturedScope, "Should use scope as thisArg when not provided"); + } + }); + + this.testCase({ + name: "useSpan: should handle synchronous exception", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-exception", _self._validSpanContext); + const expectedError = new Error("sync-exception"); + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act & Assert + try { + useSpan(_self._core, span, function(scope) { + throw expectedError; + }); + Assert.ok(false, "Should have thrown an exception"); + } catch (error) { + Assert.equal(error, expectedError, "Should throw the expected error"); + const activeSpanAfterException = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterException, originalActiveSpan, "Active span should be restored after exception"); + } + } + }); + + this.testCase({ + name: "useSpan: scope.restore should be callable manually", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-manual-restore", _self._validSpanContext); + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + let scopeRestored = false; + + // Act + useSpan(_self._core, span, function(scope) { + Assert.equal(_self._otelApi.trace.getActiveSpan(), span, "Active span should be set"); + // Manually restore (though the framework will restore again) + scope.restore(); + scopeRestored = true; + }, _self); + // Assert + Assert.ok(scopeRestored, "Scope restore should have been called"); + const activeSpanAfterCallback = this._otelApi.trace.getActiveSpan(); + Assert.equal(activeSpanAfterCallback, originalActiveSpan, "Active span should be restored"); + } + }); + + this.testCase({ + name: "useSpan: should handle promise rejection with ts-async doAwait", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-doawait-reject", _self._validSpanContext); + const expectedError = new Error("doAwait-error"); + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = useSpan(_self._core, span, function(scope) { + return createPromise((resolve, reject) => { + setTimeout(() => reject(expectedError), 5); + }); + }); + + // Use doAwait pattern to handle rejection + return doAwait(promise, + (value) => { + Assert.ok(false, "Should not resolve"); + }, + (reason) => { + Assert.equal(reason, expectedError, "Should reject with expected error"); + Assert.equal(this._otelApi.trace.getActiveSpan(), originalActiveSpan, "Active span should be restored after rejection"); + } + ); + } + }); + + this.testCase({ + name: "useSpan: should handle complex promise chain", + test: () => { + // Arrange + const _self = this; + const span = createNonRecordingSpan(_self._otelApi, "test-chain", _self._validSpanContext); + const originalActiveSpan = _self._otelApi.trace.getActiveSpan(); + + // Act + const promise = useSpan(_self._core, span, function(scope) { + return createPromise((resolve) => { + setTimeout(() => resolve(1), 5); + }); + }); + + // Chain multiple operations + return doAwait(promise, (value) => { + Assert.equal(value, 1, "First promise should resolve with 1"); + + return doAwait(createPromise((resolve) => { + setTimeout(() => resolve(value + 1), 5); + }), (value2) => { + Assert.equal(value2, 2, "Second promise should resolve with 2"); + Assert.equal(this._otelApi.trace.getActiveSpan(), originalActiveSpan, "Active span should be restored"); + }); + }); + } + }); + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index a62647c5b..74b7b9fd5 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -14,7 +14,13 @@ import { DynamicConfigTests } from "./DynamicConfig.Tests"; import { SendPostManagerTests } from './SendPostManager.Tests'; // import { StatsBeatTests } from './StatsBeat.Tests'; import { OTelTraceApiTests } from './OpenTelemetry/traceState.Tests'; +import { CommonUtilsTests } from './OpenTelemetry/commonUtils.Tests'; +import { OpenTelemetryErrorsTests } from './OpenTelemetry/errors.Tests'; +import { SpanTests } from './OpenTelemetry/span.Tests'; +import { AttributeContainerTests } from './OpenTelemetry/attributeContainer.Tests'; import { W3cTraceStateTests } from './W3TraceState.Tests'; +import { OTelNegativeTests } from './OpenTelemetry/otelNegative.Tests'; +import { TraceUtilsTests } from './OpenTelemetry/traceUtils.Tests'; export function runTests() { new GlobalTestHooks().registerTests(); @@ -31,7 +37,13 @@ export function runTests() { new EventsDiscardedReasonTests().registerTests(); new W3cTraceParentTests().registerTests(); new OTelTraceApiTests().registerTests(); + new CommonUtilsTests().registerTests(); + new OpenTelemetryErrorsTests().registerTests(); + new SpanTests().registerTests(); + new AttributeContainerTests().registerTests(); new W3cTraceStateTests().registerTests(); + new TraceUtilsTests().registerTests(); + new OTelNegativeTests().registerTests(); // new StatsBeatTests(false).registerTests(); // new StatsBeatTests(true).registerTests(); new SendPostManagerTests().registerTests(); diff --git a/shared/AppInsightsCore/src/Config/DynamicSupport.ts b/shared/AppInsightsCore/src/Config/DynamicSupport.ts index c7e8e2193..44cb41d0f 100644 --- a/shared/AppInsightsCore/src/Config/DynamicSupport.ts +++ b/shared/AppInsightsCore/src/Config/DynamicSupport.ts @@ -53,9 +53,9 @@ export function _cfgDeepCopy(source: T): T { } /** - * @internal * Get the dynamic config handler if the value is already dynamic - * @returns + * @param value - The value to check for dynamic config handler + * @returns The dynamic config handler if present, null otherwise */ export function getDynamicConfigHandler(value: V | IDynamicConfigHandler): IDynamicConfigHandler | null { if (value) { diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Enums/DependencyTypes.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/DependencyTypes.ts new file mode 100644 index 000000000..b38711dcb --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/DependencyTypes.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createEnumStyle } from "./EnumHelperFuncs"; + +export const enum eDependencyTypes { + InProc = "InProc", + QueueMessage = "Queue Message", + Sql = "SQL", + Http = "Http", + Grpc = "GRPC", + Wcf = "WCF Service", +} + +/** + * The EventsDiscardedReason enumeration contains a set of values that specify the reason for discarding an event. + */ +export const DependencyTypes = (/* @__PURE__ */ createEnumStyle({ + /** + * InProc + */ + InProc: eDependencyTypes.InProc, + + /** + * Quene Message + */ + QueueMessage: eDependencyTypes.QueueMessage, + + /** + * Sql + */ + Sql: eDependencyTypes.Sql, + + /** + * Http + */ + Http: eDependencyTypes.Http, + + /** + * Grpc + */ + Grpc: eDependencyTypes.Grpc, + + /** + * Wcf + */ + Wcf: eDependencyTypes.Wcf +})); + +export type DependencyTypes = string | eDependencyTypes; \ No newline at end of file diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Enums/LoggingEnums.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/LoggingEnums.ts index 2ff7d885c..3e3f76145 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Enums/LoggingEnums.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/LoggingEnums.ts @@ -130,6 +130,10 @@ export const enum _eInternalMessageId { InitPromiseException = 112, StatsBeatManagerException = 113, StatsBeatException = 114, + AttributeError = 115, + SpanError = 116, + TraceError = 117, + NotImplementedError = 118 } export type _InternalMessageId = number | _eInternalMessageId; diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts index 1ed4a92f3..d271715fa 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { IPromise } from "@nevware21/ts-async"; -import { ITimerHandler } from "@nevware21/ts-utils"; +import { ICachedValue, ITimerHandler } from "@nevware21/ts-utils"; import { WatcherFunction } from "../Config/IDynamicWatcher"; import { eActiveStatus } from "../JavaScriptSDK.Enums/InitActiveStatusEnum"; import { SendRequestReason } from "../JavaScriptSDK.Enums/SendRequestReason"; @@ -11,7 +11,6 @@ import { IChannelControls } from "./IChannelControls"; import { IConfiguration } from "./IConfiguration"; import { ICookieMgr } from "./ICookieMgr"; import { IDiagnosticLogger } from "./IDiagnosticLogger"; -import { IDistributedTraceContext } from "./IDistributedTraceContext"; import { INotificationListener } from "./INotificationListener"; import { INotificationManager } from "./INotificationManager"; import { IPerfManagerProvider } from "./IPerfManager"; @@ -20,6 +19,7 @@ import { ITelemetryInitializerHandler, TelemetryInitializerFunction } from "./IT import { ITelemetryItem } from "./ITelemetryItem"; import { IPlugin, ITelemetryPlugin } from "./ITelemetryPlugin"; import { ITelemetryUnloadState } from "./ITelemetryUnloadState"; +import { ITraceHost, ITraceProvider } from "./ITraceProvider"; import { ILegacyUnloadHook, IUnloadHook } from "./IUnloadHook"; // import { IStatsBeat, IStatsBeatState } from "./IStatsBeat"; @@ -45,12 +45,7 @@ export interface ILoadedPlugin { remove: (isAsync?: boolean, removeCb?: (removed?: boolean) => void) => void; } -export interface IAppInsightsCore extends IPerfManagerProvider { - - /* - * Config object used to initialize AppInsights - */ - readonly config: CfgType; +export interface IAppInsightsCore extends IPerfManagerProvider, ITraceHost { /** * The current logger instance for this instance. @@ -221,15 +216,13 @@ export interface IAppInsightsCore void, sendReason?: SendRequestReason, cbTimeout?: number): boolean | void; /** - * Gets the current distributed trace active context for this instance - * @param createNew - Optional flag to create a new instance if one doesn't currently exist, defaults to true - */ - getTraceCtx(createNew?: boolean): IDistributedTraceContext | null; - - /** - * Sets the current distributed trace context for this instance if available + * Set the trace provider for creating spans. + * This allows different SKUs to provide their own span implementations. + * + * @param provider - The trace provider to use for span creation + * @since 3.4.0 */ - setTraceCtx(newTraceCtx: IDistributedTraceContext | null | undefined): void; + setTraceProvider(provider: ICachedValue): void; /** * Watches and tracks changes for accesses to the current config, and if the accessed config changes the diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts index 66b473010..69f80d50c 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { IPromise } from "@nevware21/ts-async"; import { eTraceHeadersMode } from "../JavaScriptSDK.Enums/TraceHeadersMode"; +import { IOTelConfig } from "../OpenTelemetry/interfaces/config/IOTelConfig"; import { IAppInsightsCore } from "./IAppInsightsCore"; import { IChannelControls } from "./IChannelControls"; import { ICookieMgrConfig } from "./ICookieMgr"; @@ -14,7 +15,7 @@ import { ITelemetryPlugin } from "./ITelemetryPlugin"; /** * Configuration provided to SDK core */ -export interface IConfiguration { +export interface IConfiguration extends IOTelConfig { /** * Instrumentation key of resource. Either this or connectionString must be specified. */ diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDiagnosticLogger.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDiagnosticLogger.ts index cb4b03f17..619c810c9 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDiagnosticLogger.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDiagnosticLogger.ts @@ -73,4 +73,9 @@ export interface IDiagnosticLogger { * / Promise to allow any listeners to wait for the operation to complete. */ unload?(isAsync?: boolean): void | IPromise; + + /** + * A flag that indicates whether this logger is in debug (throw real exceptions) mode + */ + readonly dbgMode?: boolean; } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts index 3778a766e..ccb28ce4d 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts @@ -3,6 +3,180 @@ import { IW3cTraceState } from "./IW3cTraceState"; +/** + * An object that can be used to populate a new {@link IDistributedTraceContext} instance, + * the included {@link IW3cTraceState} is used as the parent of the created instances traceState + */ +export interface IDistributedTraceInit { + /** + * The unique identifier for the trace that this span belongs to. + * + * The trace ID is a globally unique identifier that connects all spans within a single + * distributed trace. It consists of 16 randomly generated bytes encoded as 32 lowercase + * hexadecimal characters, providing 128 bits of entropy to ensure worldwide uniqueness + * with practically sufficient probability. + * + * @remarks + * - Must be exactly 32 lowercase hexadecimal characters + * - Represents 128 bits (16 bytes) of random data + * - Shared by all spans within the same trace + * - Used for trace correlation across distributed systems + * - Should never be all zeros (invalid trace ID) + * + * @example + * ```typescript + * // Example trace ID format + * const traceId = "4bf92f3577b34da6a3ce929d0e0e4736"; + * + * // All spans in the same trace share this ID + * console.log(parentSpan.spanContext().traceId === childSpan.spanContext().traceId); // true + * ``` + */ + traceId: string; + + /** + * The unique identifier for this specific span within the trace. + * + * The span ID uniquely identifies this span within the trace and is used to establish + * parent-child relationships between spans. It consists of 8 randomly generated bytes + * encoded as 16 lowercase hexadecimal characters, providing 64 bits of entropy to + * ensure global uniqueness with practically sufficient probability. + * + * @remarks + * - Must be exactly 16 lowercase hexadecimal characters + * - Represents 64 bits (8 bytes) of random data + * - Unique within the trace (different spans have different span IDs) + * - Used as parent ID when creating child spans + * - Should never be all zeros (invalid span ID) + * + * @example + * ```typescript + * // Example span ID format + * const spanId = "00f067aa0ba902b7"; + * + * // Each span has a unique ID within the trace + * const parentId = parentSpan.spanContext().spanId; // "00f067aa0ba902b7" + * const childId = childSpan.spanContext().spanId; // "b9c7c989f97918e1" + * + * // Child span uses parent's span ID as its parent ID + * console.log(childSpan.parentSpanId === parentId); // true + * ``` + */ + spanId: string; + + /** + * Indicates whether this span context was propagated from a remote parent span. + * + * This flag distinguishes between spans created locally within the same process + * and spans that represent operations in remote services. Remote spans are typically + * created when trace context is received via HTTP headers, message queues, or other + * inter-process communication mechanisms. + * + * @defaultValue false - spans are considered local unless explicitly marked as remote + * + * @remarks + * - True only when span context was received from another process/service + * - Helps distinguish local vs. distributed trace segments + * - Used by tracing systems for visualization and analysis + * - Local child spans of remote parents are NOT considered remote themselves + * + * @example + * ```typescript + * // HTTP service receiving trace context + * const incomingSpanContext = extractSpanContextFromHeaders(request.headers); + * console.log(incomingSpanContext.isRemote); // true + * + * // Child span created locally + * const localChild = tracer.startSpan('local-operation', { + * parent: incomingSpanContext + * }); + * console.log(localChild.spanContext().isRemote); // false + * ``` + */ + isRemote?: boolean; + + /** + * Trace flags that control trace behavior and indicate sampling decisions. + * + * The trace flags are represented as a single byte (8-bit bitmap) that carries + * trace-level information. The least significant bit (0x01) indicates whether + * the trace is sampled. When this bit is set, it documents that the caller + * may have recorded trace data. Additional bits are reserved for future use + * and should be ignored when not understood. + * + * @remarks + * - Represented as a number (0-255) corresponding to 8 bits + * - Bit 0 (0x01): Sampled flag - indicates trace may contain recorded data + * - Bits 1-7: Reserved for future use, should be preserved during propagation + * - Used by sampling algorithms to make consistent decisions across services + * - See {@link eW3CTraceFlags} for standard flag values + * + * @example + * ```typescript + * // Check if trace is sampled + * const isSampled = (spanContext.traceFlags & 0x01) === 1; + * + * // Common flag values + * const UNSAMPLED = 0x00; // 00000000 - not sampled + * const SAMPLED = 0x01; // 00000001 - sampled + * + * // Preserving unknown flags during propagation + * const preservedFlags = spanContext.traceFlags | 0x01; // Set sampled bit while preserving others + * + * // W3C traceparent header format includes these flags + * const traceparent = `00-${traceId}-${spanId}-${traceFlags.toString(16).padStart(2, '0')}`; + * ``` + */ + traceFlags: number; + + /** + * Vendor-specific trace state information for cross-system trace correlation. + * + * The trace state carries tracing-system-specific context in a standardized format + * defined by the W3C Trace Context specification. It allows multiple tracing systems + * to participate in the same trace by providing a mechanism for each system to add + * its own metadata without interfering with others. + * + * The trace state is formatted as a comma-separated list of key-value pairs, where + * each pair represents one tracing system's contribution. Keys should be unique + * within the trace state and follow specific naming conventions. + * + * @remarks + * - Maximum of 32 list members allowed + * - Each member format: `key=value` separated by commas + * - Keys should be namespaced to avoid conflicts (e.g., `vendor@system=value`) + * - Total size should not exceed 512 characters for practical header limits + * - Spaces around list members are ignored + * - Preserves vendor-specific information during trace propagation + * + * @see {@link https://www.w3.org/TR/trace-context/#tracestate-field | W3C Trace Context Specification} + * + * @example + * ```typescript + * // Single tracing system + * const singleVendor = { + * get: (key: string) => key === 'rojo' ? '00f067aa0ba902b7' : undefined, + * set: (key: string, value: string) => { ... }, + * unset: (key: string) => { ... }, + * serialize: () => 'rojo=00f067aa0ba902b7' + * }; + * + * // Multiple tracing systems + * const multiVendor = { + * serialize: () => 'rojo=00f067aa0ba902b7,congo=t61rcWkgMzE,vendor@system=custom-value' + * }; + * + * // Accessing trace state + * const rojoValue = spanContext.traceState?.get('rojo'); + * const serialized = spanContext.traceState?.serialize(); + * + * // HTTP header format + * headers['tracestate'] = spanContext.traceState?.serialize() || ''; + * ``` + */ + traceState?: IW3cTraceState; +} + export interface IDistributedTraceContext { /** @@ -89,8 +263,12 @@ export interface IDistributedTraceContext { * with practically sufficient probability by being made as 16 randomly * generated bytes, encoded as a 32 lowercase hex characters corresponding to * 128 bits. + * @remarks It is NOT recommended that you dynamically change this value after creation and it is actively + * being used as this may affect anyone accessing this context (as a parent for instance). You should logically + * treat this as readonly after creation. * @remarks If you update this value, it will only update for the current context, not the parent context, - * if you need to update the current and ALL parent contexts, use the `setTraceId` method. + * if you need to update the current and ALL parent contexts, use the `setTraceId` method which + * provides the previous behavior. * @since 3.4.0 */ traceId: string; @@ -132,8 +310,8 @@ export interface IDistributedTraceContext { /** * Returns the current trace state which will be used to propgate context across different services. - * Updating (adding / removing keys) of the trace state will modify the current context. - * @remarks Unlike the OpenTelemetry {@link TraceState}, this value is a mutable object, so you can + * Updating (adding / removing keys) of the trace state will modify the current context.IOTelTraceState + * @remarks Unlike the OpenTelemetry {@link IOTelTraceState}, this value is a mutable object, so you can * modify it directly you do not need to reassign the new value to this property. * @since 3.4.0 */ diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IFeatureOptIn.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IFeatureOptIn.ts index 0503a051a..b6e8c537f 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IFeatureOptIn.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IFeatureOptIn.ts @@ -10,14 +10,14 @@ export interface IFeatureOptInDetails { * Identifies configuration override values when given feature is enabled * NOTE: should use flat string for fields, for example, if you want to set value for extensionConfig.Ananlytics.disableAjaxTrackig in configurations, * you should use "extensionConfig.Ananlytics.disableAjaxTrackig" as field name: \{["extensionConfig.Analytics.disableAjaxTrackig"]:1\} - * Default: undefined + * @default undefined */ onCfg?: {[field: string]: any}; /** * Identifies configuration override values when given feature is disabled * NOTE: should use flat string for fields, for example, if you want to set value for extensionConfig.Ananlytics.disableAjaxTrackig in configurations, * you should use "extensionConfig.Ananlytics.disableAjaxTrackig" as field name: \{["extensionConfig.Analytics.disableAjaxTrackig"]:1\} - * Default: undefined + * @default undefined */ offCfg?: {[field: string]: any}; /** diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/ITraceProvider.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/ITraceProvider.ts new file mode 100644 index 000000000..655bc355d --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/ITraceProvider.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IOTelApi } from "../OpenTelemetry/interfaces/IOTelApi"; +import { IOTelSpanOptions } from "../OpenTelemetry/interfaces/trace/IOTelSpanOptions"; +import { IReadableSpan } from "../OpenTelemetry/interfaces/trace/IReadableSpan"; +import { IConfiguration } from "./IConfiguration"; +import { IDistributedTraceContext } from "./IDistributedTraceContext"; + +/** + * A trace provider interface that enables different SKUs to provide their own + * span implementations while being managed by the core SDK. + * + * This follows the OpenTelemetry TraceProvider pattern, allowing the core to + * delegate span creation to the appropriate implementation based on the SDK variant. + * + * @since 3.4.0 + */ +export interface ITraceProvider { + /** + * The OpenTelemetry API instance associated with this trace provider. + * This provides access to the tracer provider and other OpenTelemetry functionality. + * @since 3.4.0 + */ + readonly api: IOTelApi; + + /** + * Creates a new span with the given name and options. + * + * @param name - The name of the span + * @param options - Options for creating the span (kind, attributes, startTime) + * @param parent - Optional parent context. If not provided, uses the current active trace context + * @returns A new span instance specific to this provider's implementation + * @since 3.4.0 + */ + createSpan(name: string, options?: IOTelSpanOptions, parent?: IDistributedTraceContext): IReadableSpan; + + /** + * Gets the provider identifier for debugging and logging purposes. + * @returns A string identifying this trace provider implementation + * @since 3.4.0 + */ + getProviderId(): string; + + /** + * Determines if this provider is available and ready to create spans. + * @returns true if the provider can create spans, false otherwise + * @since 3.4.0 + */ + isAvailable(): boolean; +} + +/** + * Interface for OpenTelemetry trace operations. + * This interface provides span creation, context management, and trace provider operations + * that are common across different SDK implementations (Core, AISKU, etc.). + * + * @since 3.4.0 + */ +export interface ITraceHost { + + /* + * Config object that was used to initialize AppInsights / ITraceHost + */ + readonly config: CfgType; + + /** + * Gets the current distributed trace active context for this instance + * @param createNew - Optional flag to create a new instance if one doesn't currently exist, defaults to true. By default this + * will use any located parent as defined by the {@link IConfiguration.traceHdrMode} configuration for each new instance created. + */ + getTraceCtx(createNew?: boolean): IDistributedTraceContext | null; + + /** + * Sets the current distributed trace context for this instance if available + */ + setTraceCtx(newTraceCtx: IDistributedTraceContext | null | undefined): void; + + /** + * Start a new span with the given name and optional parent context. + * + * Note: This method only creates and returns the span. It does not automatically + * set the span as the active trace context. Context management should be handled + * separately using setTraceCtx() if needed. + * + * @param name - The name of the span + * @param options - Options for creating the span (kind, attributes, startTime) + * @param parent - Optional parent context. If not provided, uses the current active trace context + * @returns A new span instance, or null if no trace provider is available + * @since 3.4.0 + * + * @see {@link IReadableSpan} - Interface for individual spans + * @see {@link IOTelSpanOptions} - Configuration options for span creation + */ + startSpan(name: string, options?: IOTelSpanOptions, parent?: IDistributedTraceContext): IReadableSpan | null; + + /** + * Return the current active span, if no trace provider is available null will be returned + * but when a trace provider is available a span instance will always be returned, even if + * there is no active span (in which case a non-recording span will be returned). + * @param createNew - Optional flag to create a non-recording span if no active span exists, defaults to true. + * When false, returns the existing active span or null without creating a non-recording span, which can improve + * performance when only checking if an active span exists. + * @returns The current active span or null if no trace provider is available or if createNew is false and no active span exists + * @since 3.4.0 + */ + getActiveSpan(createNew?: boolean): IReadableSpan | null; + + /** + * Set the current Active Span, if no trace provider is available the span will be not be set as the active span. + * @param span - The span to set as the active span + * @returns An ISpanScope instance that provides the current scope, the span will always be the span passed in + * even when no trace provider is available + * @since 3.4.0 + */ + setActiveSpan(span: IReadableSpan): ISpanScope + + /** + * Get the current trace provider. + * + * @returns The current trace provider, or null if none is set + * @since 3.4.0 + */ + getTraceProvider(): ITraceProvider | null; +} + +/** + * Represents the execution scope for a span, combining the trace instance and the active span. + * This interface is used as the context for executing functions within a span's scope. + * + * @since 3.4.0 + */ +export interface ISpanScope { + /** + * The trace host (core or AISKU instance). + * @since 3.4.0 + */ + readonly host: T; + + /** + * The active span for this execution scope. + * @since 3.4.0 + */ + readonly span: IReadableSpan; + + /** + * The previously active span before this scope was created, if any. + * @since 3.4.0 + */ + readonly prvSpan?: IReadableSpan; + + /** + * Restores the previous active span in the trace instance. + * @since 3.4.0 + */ + restore(): void; +} diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts b/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts index ebef72a3b..af399fec1 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts @@ -4,8 +4,8 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { IPromise, createPromise, createSyncAllSettledPromise, doAwaitResponse } from "@nevware21/ts-async"; import { - ITimerHandler, arrAppend, arrForEach, arrIndexOf, createTimeout, deepExtend, hasDocument, isFunction, isNullOrUndefined, isPlainObject, - isPromiseLike, objDeepFreeze, objDefine, objForEachKey, objFreeze, objHasOwn, scheduleTimeout, throwError + ICachedValue, ITimerHandler, arrAppend, arrForEach, arrIndexOf, createTimeout, deepExtend, hasDocument, isFunction, isNullOrUndefined, + isPlainObject, isPromiseLike, objAssign, objDeepFreeze, objDefine, objForEachKey, objFreeze, objHasOwn, scheduleTimeout, throwError } from "@nevware21/ts-utils"; import { cfgDfMerge } from "../Config/ConfigDefaultHelpers"; import { createDynamicConfig, onConfigChange } from "../Config/DynamicConfig"; @@ -36,10 +36,12 @@ import { IPlugin, ITelemetryPlugin } from "../JavaScriptSDK.Interfaces/ITelemetr import { ITelemetryPluginChain } from "../JavaScriptSDK.Interfaces/ITelemetryPluginChain"; import { ITelemetryUnloadState } from "../JavaScriptSDK.Interfaces/ITelemetryUnloadState"; import { ITelemetryUpdateState } from "../JavaScriptSDK.Interfaces/ITelemetryUpdateState"; +import { ITraceProvider } from "../JavaScriptSDK.Interfaces/ITraceProvider"; import { ILegacyUnloadHook, IUnloadHook } from "../JavaScriptSDK.Interfaces/IUnloadHook"; -import { IOTelSpanContext } from "../OpenTelemetry/interfaces/trace/IOTelSpanContext"; -import { createOTelSpanContext } from "../OpenTelemetry/trace/spanContext"; -import { createOTelTraceState } from "../OpenTelemetry/trace/traceState"; +import { ITraceCfg } from "../OpenTelemetry/interfaces/config/ITraceCfg"; +import { IOTelSpanOptions } from "../OpenTelemetry/interfaces/trace/IOTelSpanOptions"; +import { IReadableSpan } from "../OpenTelemetry/interfaces/trace/IReadableSpan"; +import { ISpanScope } from "../applicationinsights-core-js"; import { doUnloadAll, runTargetUnload } from "./AsyncUtils"; import { ChannelControllerPriority } from "./Constants"; import { createCookieMgr } from "./CookieMgr"; @@ -55,7 +57,9 @@ import { PerfManager, doPerf, getGblPerfMgr } from "./PerfManager"; import { createProcessTelemetryContext, createProcessTelemetryUnloadContext, createProcessTelemetryUpdateContext, createTelemetryProxyChain } from "./ProcessTelemetryContext"; -import { _getPluginState, createDistributedTraceContext, initializePlugins, sortPlugins } from "./TelemetryHelpers"; +import { + _getPluginState, createDistributedTraceContext, initializePlugins, isDistributedTraceContext, sortPlugins +} from "./TelemetryHelpers"; import { TelemetryInitializerPlugin } from "./TelemetryInitializerPlugin"; import { IUnloadHandlerContainer, UnloadHandler, createUnloadHandlerContainer } from "./UnloadHandlerContainer"; import { IUnloadHookContainer, createUnloadHookContainer } from "./UnloadHookContainer"; @@ -70,6 +74,7 @@ const strSdkUnloadingError = "SDK is still unloading..."; const strSdkNotInitialized = "SDK is not initialized"; const maxInitQueueSize = 100; const maxInitTimeout = 50000; +const maxAttributeCount = 128; // const strPluginUnloadFailed = "Failed to unload plugin"; // /** @@ -102,10 +107,60 @@ const defaultConfig: IConfigDefaults = objDeepFreeze({ [STR_CREATE_PERF_MGR]: UNDEFINED_VALUE, loggingLevelConsole: eLoggingSeverity.DISABLED, diagnosticLogInterval: UNDEFINED_VALUE, - traceHdrMode: eTraceHeadersMode.All + traceHdrMode: eTraceHeadersMode.All, + traceCfg: cfgDfMerge({ + generalLimits: cfgDfMerge({ + attributeValueLengthLimit: undefined, + attributeCountLimit: maxAttributeCount + }), + // spanLimits: cfgDfMerge({ + // attributeValueLengthLimit: undefined, + // attributeCountLimit: maxAttributeCount, + // linkCountLimit: maxAttributeCount, + // eventCountLimit: maxAttributeCount, + // attributePerEventCountLimit: maxAttributeCount, + // attributePerLinkCountLimit: maxAttributeCount + // }), + // idGenerator: null, + serviceName: null, + suppressTracing: false + }) // _sdk: { rdOnly: true, ref: true, v: defaultSdkConfig } }); +function _getDefaultConfig(core: IAppInsightsCore): IConfigDefaults { + let handlers = { + // Dynamic Default Error Handlers + errorHandlers: cfgDfMerge({ + attribError: (message: string, key: string, value: any) => { + core.logger.throwInternal(eLoggingSeverity.WARNING, _eInternalMessageId.AttributeError, message, { + attribName: key, + value: value + }); + }, + spanError: (message: string, spanName: string) => { + core.logger.throwInternal(eLoggingSeverity.WARNING, _eInternalMessageId.SpanError, message, { + spanName: spanName + }); + }, + debug: (message: string) => { + core.logger.debugToConsole(message); + }, + warn: (message: string) => { + core.logger.warnToConsole(message) + }, + error: (message: string) => { + core.logger.throwInternal(eLoggingSeverity.CRITICAL, _eInternalMessageId.TraceError, message); + }, + notImplemented: (message: string) => { + core.logger.throwInternal(eLoggingSeverity.CRITICAL, _eInternalMessageId.NotImplementedError, message); + } + }) + }; + + return objDeepFreeze(objAssign({}, defaultConfig as any, handlers)); +} + /** * Helper to create the default performance manager * @param core - The AppInsightsCore instance @@ -267,24 +322,28 @@ function _createUnloadHook(unloadHook: IUnloadHook): IUnloadHook { }, "toJSON", { v: () => "aicore::onCfgChange<" + JSON.stringify(unloadHook) + ">" }); } -function _getParentTraceCtx(mode: eTraceHeadersMode): IOTelSpanContext | null { - let spanContext: IOTelSpanContext | null = null; +function _getParentTraceCtx(mode: eTraceHeadersMode): IDistributedTraceContext | null { + let spanContext: IDistributedTraceContext | null = null; const parentTrace = (mode & eTraceHeadersMode.TraceParent) ? findW3cTraceParent() : null; const parentTraceState = (mode & eTraceHeadersMode.TraceState) ? findW3cTraceState() : null; if (parentTrace || parentTraceState) { - spanContext = createOTelSpanContext({ + spanContext = createDistributedTraceContext({ traceId: parentTrace ? parentTrace.traceId : null, spanId: parentTrace ? parentTrace.spanId : null, traceFlags: parentTrace ? parentTrace.traceFlags : UNDEFINED_VALUE, isRemote: true, // Mark as remote since it's from an external source - traceState: parentTraceState ? createOTelTraceState(parentTraceState) : null + traceState: parentTraceState }); } return spanContext; } +function _noOpFunc() { + // No-op function +} + /** * @group Classes * @group Entrypoint @@ -331,7 +390,7 @@ export class AppInsightsCore im let _channels: IChannelControls[] | null; let _isUnloading: boolean; let _telemetryInitializerPlugin: TelemetryInitializerPlugin; - let _serverOTelCtx: IOTelSpanContext | null; + let _serverOTelCtx: IDistributedTraceContext | null; let _serverTraceHdrMode: eTraceHeadersMode; let _internalLogsEventName: string | null; let _evtNamespace: string; @@ -339,6 +398,8 @@ export class AppInsightsCore im let _hookContainer: IUnloadHookContainer; let _debugListener: INotificationListener | null; let _traceCtx: IDistributedTraceContext | null; + let _traceProvider: ICachedValue | null; + let _activeSpan: IReadableSpan | null; let _instrumentationKey: string | null; let _cfgListeners: { rm: () => void, w: WatcherFunction}[]; let _extensions: IPlugin[]; @@ -389,7 +450,7 @@ export class AppInsightsCore im throwError("Core cannot be initialized more than once"); } - _configHandler = createDynamicConfig(config, defaultConfig as any, logger || _self.logger, false); + _configHandler = createDynamicConfig(config, _getDefaultConfig(_self), logger || _self.logger, false); // Re-assigning the local config property so we don't have any references to the passed value and it can be garbage collected config = _configHandler.cfg; @@ -1004,6 +1065,132 @@ export class AppInsightsCore im _traceCtx = traceCtx || null; }; + _self.startSpan = (name: string, options?: IOTelSpanOptions, parent?: IDistributedTraceContext): IReadableSpan | null => { + if (!_traceProvider || !_traceProvider.v || !_traceProvider.v.isAvailable()) { + // No trace provider available or provider is not ready + return null; + } + + return _traceProvider.v.createSpan(name, options, parent || _self.getTraceCtx()); + }; + + /** + * Return the current active span + * @param createNew - Optional flag to create a non-recording span if no active span exists, defaults to true + */ + _self.getActiveSpan = (createNew?: boolean): IReadableSpan | null => { + // Special case for when there is no active span but there is a trace provider + if (createNew !== false && _traceProvider && !_activeSpan && _traceProvider.v) { + // Now that we have a trace provider, ensure that we return an active span (non-recording) + _activeSpan = _traceProvider.v.createSpan(_traceProvider.v.getProviderId(), { + recording: false, + root: true + }); + } + + return _activeSpan; + }; + + /** + * Set the current Active Span + * @param span - The span to set as the active span + */ + _self.setActiveSpan = (span: IReadableSpan): ISpanScope => { + let theSpanContext: IDistributedTraceContext | null = null; + let currentCtx: IDistributedTraceContext = _self.getTraceCtx(); + let currentSpan: IReadableSpan | null = _activeSpan; + let scope: ISpanScope; + + if (span) { + let otelSpanContext: IDistributedTraceContext = null; + if (span.spanContext) { + // May be a valid IDistributedTraceContext or an OpenTelemetry SpanContext + otelSpanContext = span.spanContext(); + } else if ((span as any).context) { + // Legacy OpenTelemetry API support (Note: The returned context won't be a valid IDistributedTraceContext) + otelSpanContext = (span as any).context(); + } + + if (otelSpanContext) { + if (isDistributedTraceContext(otelSpanContext)) { + theSpanContext = otelSpanContext; + } else { + // Support Spans from other libraries that may not be using the IDistributedTraceContext + // If the spanContext is not a valid IDistributedTraceContext then we need to create a new one + // and optionally set the parentSpanContext if it exists + + // Create a new context using the current trace context as the parent + theSpanContext = createDistributedTraceContext(currentCtx); + + let parentContext: any = span.parentSpanContext; + if (!parentContext) { + if (span.parentSpanId) { + parentContext = { + traceId: (otelSpanContext as any).traceId, + spanId: span.parentSpanId + }; + } + } + + // Was there a defined parent context and is it different from the current basic context + if (parentContext && parentContext.traceId !== theSpanContext.traceId && + parentContext.spanId !== theSpanContext.spanId && + parentContext.traceFlags !== theSpanContext.traceFlags) { + + // Assign the parent details to this new context + theSpanContext.traceId = parentContext.traceId; + theSpanContext.spanId = parentContext.spanId; + theSpanContext.traceFlags = parentContext.traceFlags; + + // Now create a new "Child" context which is extending the parent context + theSpanContext = createDistributedTraceContext(theSpanContext); + } + + theSpanContext.traceId = (otelSpanContext as any).traceId; + theSpanContext.spanId = (otelSpanContext as any).spanId; + theSpanContext.traceFlags = (otelSpanContext as any).traceFlags; + } + } + } + + scope = { + host: _self, + span: span, + prvSpan: currentSpan, + restore: () => { + // Restore the current span and trace context + if (currentSpan) { + _self.setActiveSpan(currentSpan); + } else { + _activeSpan = null; + _self.setTraceCtx(currentCtx); + } + + // Clear the restore function, so that multiple calls to restore do not have any effect + scope.restore = _noOpFunc; + } + }; + + // Change the active span to the new span + _activeSpan = span; + + // Set the current trace context for the core SDK + // This is REQUIRED for the SDK to correctly associate telemetry with the current span context + if (theSpanContext) { + _self.setTraceCtx(theSpanContext); + } + + return scope; + }; + + _self.setTraceProvider = (traceProvider: ICachedValue): void => { + _traceProvider = traceProvider; + }; + + _self.getTraceProvider = (): ITraceProvider | null => { + return _traceProvider ? _traceProvider.v : null; + }; + _self.addUnloadHook = _addUnloadHook; // Create the addUnloadCb @@ -1138,6 +1325,7 @@ export class AppInsightsCore im _evtNamespace = createUniqueNamespace("AIBaseCore", true); _unloadHandlers = createUnloadHandlerContainer(); _traceCtx = null; + _traceProvider = null; _instrumentationKey = null; _hookContainer = createUnloadHookContainer(); _cfgListeners = []; @@ -1688,6 +1876,71 @@ export class AppInsightsCore im // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } + /** + * Start a new span with the given name and optional parent context. + * The span will become the active span for its duration unless a different + * span is explicitly set as active. + * + * @param name - The name of the span + * @param options - Options for creating the span (kind, attributes, startTime) + * @param parent - Optional parent context. If not provided, uses the current active trace context + * @returns A new span instance, or null if no trace provider is available + * @since 3.4.0 + */ + public startSpan(name: string, options?: IOTelSpanOptions, parent?: IDistributedTraceContext): IReadableSpan | null { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + /** + * Return the current active span, if no trace provider is available null will be returned + * but when a trace provider is available a span instance will always be returned, even if + * there is no active span (in which case a non-recording span will be returned). + * @param createNew - Optional flag to create a non-recording span if no active span exists, defaults to true. + * When false, returns the existing active span or null without creating a non-recording span. + * @returns The current active span or null if no trace provider is available or if createNew is false and no active span exists + * @since 3.4.0 + */ + public getActiveSpan(createNew?: boolean): IReadableSpan | null { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + /** + * Set the current Active Span + * @param span - The span to set as the active span + * @returns An ISpanScope instance that provides the current scope, the span will always be the span passed in + * even when no trace provider is available + * @since 3.4.0 + */ + public setActiveSpan(span: IReadableSpan): ISpanScope { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + /** + * Set the trace provider for creating spans. + * This allows different SKUs to provide their own span implementations. + * + * @param provider - The trace provider to use for span creation, it is passed as a cached value so that it may + * be implemented via a lazy / deferred initializer. + * @since 3.4.0 + */ + public setTraceProvider(provider: ICachedValue): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + /** + * Get the current trace provider. + * + * @returns The current trace provider, or null if none is set + * @since 3.4.0 + */ + public getTraceProvider(): ITraceProvider | null { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + /** * Add this hook so that it is automatically removed during unloading * @param hooks - The single hook or an array of IInstrumentHook objects diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/DiagnosticLogger.ts b/shared/AppInsightsCore/src/JavaScriptSDK/DiagnosticLogger.ts index 9fa60e83e..c5ca62bc9 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/DiagnosticLogger.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/DiagnosticLogger.ts @@ -3,7 +3,7 @@ "use strict" import dynamicProto from "@microsoft/dynamicproto-js"; import { IPromise } from "@nevware21/ts-async"; -import { dumpObj, isFunction, isUndefined } from "@nevware21/ts-utils"; +import { dumpObj, isFunction, isUndefined, objDefine } from "@nevware21/ts-utils"; import { createDynamicConfig, onConfigChange } from "../Config/DynamicConfig"; import { LoggingSeverity, _InternalMessageId, _eInternalMessageId, eLoggingSeverity } from "../JavaScriptSDK.Enums/LoggingEnums"; import { IAppInsightsCore } from "../JavaScriptSDK.Interfaces/IAppInsightsCore"; @@ -101,6 +101,8 @@ export function safeGetLogger(core: IAppInsightsCore, config?: IConfiguration): export class DiagnosticLogger implements IDiagnosticLogger { public identifier = "DiagnosticLogger"; + public readonly dbgMode: boolean; + /** * The internal logging queue */ @@ -193,6 +195,10 @@ export class DiagnosticLogger implements IDiagnosticLogger { _unloadHandler = null; }; + objDefine(_self, "dbgMode", { + g: () => _enableDebug + }); + function _logInternalMessage(severity: LoggingSeverity, message: _InternalLogMessage): void { if (_areInternalMessagesThrottled()) { return; diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/HelperFuncs.ts b/shared/AppInsightsCore/src/JavaScriptSDK/HelperFuncs.ts index 2d9ca9362..52993a836 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/HelperFuncs.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/HelperFuncs.ts @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ObjAssign, ObjClass } from "@microsoft/applicationinsights-shims"; +import { ObjAssign, ObjClass, ObjProto } from "@microsoft/applicationinsights-shims"; import { - arrForEach, asString as asString21, isArray, isBoolean, isError, isFunction, isNullOrUndefined, isNumber, isObject, isPlainObject, - isString, isUndefined, objDeepFreeze, objDefine, objForEachKey, objHasOwn, strIndexOf, strTrim + ICachedValue, WellKnownSymbols, arrForEach, asString as asString21, createCachedValue, getKnownSymbol, isArray, isBoolean, isError, + isFunction, isNullOrUndefined, isNumber, isObject, isPlainObject, isString, isUndefined, newSymbol, objCreate, objDeepFreeze, objDefine, + objForEachKey, objGetPrototypeOf, objHasOwn, objSetPrototypeOf, safe, strIndexOf, strTrim } from "@nevware21/ts-utils"; import { FeatureOptInMode } from "../JavaScriptSDK.Enums/FeatureOptInEnums"; import { TransportType } from "../JavaScriptSDK.Enums/SendRequestReason"; @@ -20,6 +21,8 @@ const rCamelCase = /-([a-z])/g; const rNormalizeInvalid = /([^\w\d_$])/g; const rLeadingNumeric = /^(\d+[\w\d_$])/; +let _ProtoNameTag: ICachedValue; + export let _getObjProto = Object[strGetPrototypeOf]; export function isNotUndefined(value: T): value is T { @@ -72,7 +75,7 @@ export function strContains(value: string, search: string): boolean { * Convert a date to I.S.O. format in IE8 */ export function toISOString(date: Date) { - return date && date.toISOString() || ""; + return date && date.toISOString() || STR_EMPTY; } export const deepFreeze: (obj: T) => T = objDeepFreeze; @@ -258,6 +261,93 @@ export function createClassFromInterface(defaults?: T) { } as new () => T; } +/** + * Set the type of the object by defining the toStringTag well known symbol, this will impact the + * Object.prototype.toString.call() results for the object. And can be used to identify the type + * in the debug output and also in the DevTools watchers window when inspecting the object etc. + * @param target - The object to set the toStringTag symbol on + * @param nameOrFunc - The name or function to use for the toStringTag + * @returns The target object + */ +export function setObjStringTag(target: T, nameOrFunc: string | (() => string)): T { + safe(objDefine, [target, getKnownSymbol(WellKnownSymbols.toStringTag), isFunction(nameOrFunc) ? { g: nameOrFunc, e: false } : { v: nameOrFunc, w: false, e: false }]); + + return target; +} + +/** + * Introduce an intermediate prototype to the target object and that sets the toStringTag on that prototype, + * this avoids directly modifying the target object and also allows multiple different "types" to be + * applied to the same target object if required. + * This is done as a best effort approach and may not always succeed due to security / object model restrictions + * So if it fails then it will fallback to just defining the toStringTag on the target object, which also may fail + * resulting in no change. + * @param target - The object to set the toStringTag symbol on + * @param name - The name or function to use for the toStringTag + * @returns The target object + */ +export function setProtoTypeName(target: T, name: string): T { + if (target) { + let proto = _getObjProto(target); + let done = false; + if (proto) { + // Set the target's prototype to the new intermediate prototype + safe(() => { + // Create a new intermediate prototype that extends the current prototype + let newProto = setObjStringTag(objCreate(proto), name); + if (!_ProtoNameTag) { + _ProtoNameTag = createCachedValue(newSymbol("ai$ProtoName")); + } + + // Tag this new prototype + objDefine(newProto, _ProtoNameTag.v as any, { + v: true, + w: false, + e: false + }); + + objSetPrototypeOf(target, newProto); + done = true; + }); + } + + if (!done) { + // Either no prototype or we failed to set it so just define the toStringTag on the target + safe(setObjStringTag, [target, name]); + } + } + + return target; +} + +/** + * Update the introduced intermediate prototype name of the target object. + * @param target - The object to look for the prototype name and update + * @param name - The updated name to apply + * @returns The target Object + */ +export function updateProtoTypeName(target: T, name: string): T { + if (_ProtoNameTag) { + // Find the Parent Proto + while (target && target !== ObjProto && !(target as any)[_ProtoNameTag.v]) { + let protoTarget = objGetPrototypeOf(target); + if (target === protoTarget) { + // Break out of any recursive case (happens on some runtimes) where the prototype of an + // object is the same prototype + break; + } + target = protoTarget; + } + + if ((target as any)[_ProtoNameTag.v]) { + // Found it so update + safe(setObjStringTag, [target, name]); + } + } + + return target; +} + /** * A helper function to assist with JIT performance for objects that have properties added / removed dynamically * this is primarily for chromium based browsers and has limited effects on Firefox and none of IE. Only call this @@ -388,7 +478,7 @@ export function getResponseText(xhr: XMLHttpRequest | IXDomainRequest) { export function formatErrorMessageXdr(xdr: IXDomainRequest, message?: string): string { if (xdr) { - return "XDomainRequest,Response:" + getResponseText(xdr) || ""; + return "XDomainRequest,Response:" + getResponseText(xdr) || STR_EMPTY; } return message; @@ -396,7 +486,7 @@ export function formatErrorMessageXdr(xdr: IXDomainRequest, message?: string): s export function formatErrorMessageXhr(xhr: XMLHttpRequest, message?: string): string { if (xhr) { - return "XMLHttpRequest,Status:" + xhr.status + ",Response:" + getResponseText(xhr) || xhr.response || ""; + return "XMLHttpRequest,Status:" + xhr.status + ",Response:" + getResponseText(xhr) || xhr.response || STR_EMPTY; } return message; diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/PerfManager.ts b/shared/AppInsightsCore/src/JavaScriptSDK/PerfManager.ts index f04e8f18c..19e43a795 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/PerfManager.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/PerfManager.ts @@ -5,6 +5,7 @@ import { isArray, isFunction, objDefine, utcNow } from "@nevware21/ts-utils"; import { INotificationManager } from "../JavaScriptSDK.Interfaces/INotificationManager"; import { IPerfEvent } from "../JavaScriptSDK.Interfaces/IPerfEvent"; import { IPerfManager, IPerfManagerProvider } from "../JavaScriptSDK.Interfaces/IPerfManager"; +import { _noopVoid } from "../OpenTelemetry/noop/noopHelpers"; import { STR_GET_PERF_MGR } from "./InternalConstants"; const strExecutionContextKey = "ctx"; @@ -130,7 +131,7 @@ export class PerfEvent implements IPerfEvent { _self.time = utcNow() - _self.start; _self.exTime = _self.time - childTime; - _self.complete = () => {}; + _self.complete = _noopVoid; }; } } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts b/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts index d0393e57b..2a1bd9cec 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts @@ -2,9 +2,7 @@ // Licensed under the MIT License. "use strict"; -import { - arrForEach, dumpObj, isArray, isFunction, isNullOrUndefined, isUndefined, objForEachKey, objFreeze, objKeys -} from "@nevware21/ts-utils"; +import { arrForEach, dumpObj, isArray, isFunction, isNullOrUndefined, isUndefined, objForEachKey, objFreeze } from "@nevware21/ts-utils"; import { _applyDefaultValue } from "../Config/ConfigDefaults"; import { createDynamicConfig } from "../Config/DynamicConfig"; import { IConfigDefaults } from "../Config/IConfigDefaults"; @@ -12,7 +10,6 @@ import { IDynamicConfigHandler } from "../Config/IDynamicConfigHandler"; import { _eInternalMessageId, eLoggingSeverity } from "../JavaScriptSDK.Enums/LoggingEnums"; import { IAppInsightsCore } from "../JavaScriptSDK.Interfaces/IAppInsightsCore"; import { IConfiguration } from "../JavaScriptSDK.Interfaces/IConfiguration"; -import { IDiagnosticLogger } from "../JavaScriptSDK.Interfaces/IDiagnosticLogger"; import { IBaseProcessingContext, IProcessTelemetryContext, IProcessTelemetryUnloadContext, IProcessTelemetryUpdateContext } from "../JavaScriptSDK.Interfaces/IProcessTelemetryContext"; @@ -21,8 +18,8 @@ import { IPlugin, ITelemetryPlugin } from "../JavaScriptSDK.Interfaces/ITelemetr import { ITelemetryPluginChain } from "../JavaScriptSDK.Interfaces/ITelemetryPluginChain"; import { ITelemetryUnloadState } from "../JavaScriptSDK.Interfaces/ITelemetryUnloadState"; import { ITelemetryUpdateState } from "../JavaScriptSDK.Interfaces/ITelemetryUpdateState"; +import { _noopVoid } from "../OpenTelemetry/noop/noopHelpers"; import { _throwInternal, safeGetLogger } from "./DiagnosticLogger"; -import { proxyFunctions } from "./HelperFuncs"; import { STR_CORE, STR_DISABLED, STR_EMPTY } from "./InternalConstants"; import { doPerf } from "./PerfManager"; import { _getPluginState } from "./TelemetryHelpers"; @@ -556,7 +553,7 @@ export function createTelemetryPluginProxy(plugin: ITelemetryPlugin, config: ICo return hasRun; } - if (!_processChain(unloadCtx, _callTeardown, "unload", () => {}, unloadState.isAsync)) { + if (!_processChain(unloadCtx, _callTeardown, "unload", _noopVoid, unloadState.isAsync)) { // Only called if we hasRun was not true unloadCtx.processNext(unloadState); } @@ -583,7 +580,7 @@ export function createTelemetryPluginProxy(plugin: ITelemetryPlugin, config: ICo return hasRun; } - if (!_processChain(updateCtx, _callUpdate, "update", () => {}, false)) { + if (!_processChain(updateCtx, _callUpdate, "update", _noopVoid, false)) { // Only called if we hasRun was not true updateCtx.processNext(updateState); } @@ -591,89 +588,3 @@ export function createTelemetryPluginProxy(plugin: ITelemetryPlugin, config: ICo return objFreeze(proxyChain); } - -/** - * This class will be removed! - * @deprecated use createProcessTelemetryContext() instead - */ -export class ProcessTelemetryContext implements IProcessTelemetryContext { - /** - * Gets the current core config instance - */ - public getCfg: () => IConfiguration; - - public getExtCfg: (identifier:string, defaultValue?: IConfigDefaults) => T; - - public getConfig: (identifier:string, field: string, defaultValue?: number | string | boolean | string[] | RegExp[] | Function) => number | string | boolean | string[] | RegExp[] | Function; - - /** - * Returns the IAppInsightsCore instance for the current request - */ - public core: () => IAppInsightsCore; - - /** - * Returns the current IDiagnosticsLogger for the current request - */ - public diagLog: () => IDiagnosticLogger; - - /** - * Helper to allow inherited classes to check and possibly shortcut executing code only - * required if there is a nextPlugin - */ - public hasNext: () => boolean; - - /** - * Returns the next configured plugin proxy - */ - public getNext: () => ITelemetryPluginChain; - - /** - * Helper to set the next plugin proxy - */ - public setNext: (nextCtx:ITelemetryPluginChain) => void; - - /** - * Call back for telemetry processing before it it is sent - * @param env - This is the current event being reported - * @param itemCtx - This is the context for the current request, ITelemetryPlugin instances - * can optionally use this to access the current core instance or define / pass additional information - * to later plugins (vs appending items to the telemetry item) - * @returns boolean (true) if there is no more plugins to process otherwise false or undefined (void) - */ - public processNext: (env: ITelemetryItem) => boolean | void; - - /** - * Synchronously iterate over the context chain running the callback for each plugin, once - * every plugin has been executed via the callback, any associated onComplete will be called. - * @param callback - The function call for each plugin in the context chain - */ - public iterate: (callback: (plugin: T) => void) => void; - - /** - * Create a new context using the core and config from the current instance - * @param plugins - The execution order to process the plugins, if null or not supplied - * then the current execution order will be copied. - * @param startAt - The plugin to start processing from, if missing from the execution - * order then the next plugin will be NOT set. - */ - public createNew: (plugins?:IPlugin[]|ITelemetryPluginChain, startAt?:IPlugin) => IProcessTelemetryContext; - - /** - * Set the function to call when the current chain has executed all processNext or unloadNext items. - */ - public onComplete: (onComplete: () => void) => void; - - /** - * Creates a new Telemetry Item context with the current config, core and plugin execution chain - * @param plugins - The plugin instances that will be executed - * @param config - The current config - * @param core - The current core instance - */ - constructor(pluginChain: ITelemetryPluginChain, config: IConfiguration, core: IAppInsightsCore, startAt?:IPlugin) { - let _self = this; - - let context = createProcessTelemetryContext(pluginChain, config, core, startAt); - // Proxy all functions of the context to this object - proxyFunctions(_self, context, objKeys(context) as any); - } -} diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts b/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts index 1540eeb3c..1a619423c 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/SenderPostManager.ts @@ -14,6 +14,7 @@ import { import { ITelemetryUnloadState } from "../JavaScriptSDK.Interfaces/ITelemetryUnloadState"; import { IXDomainRequest } from "../JavaScriptSDK.Interfaces/IXDomainRequest"; import { IPayloadData, IXHROverride, OnCompleteCallback, SendPOSTFunction } from "../JavaScriptSDK.Interfaces/IXHROverride"; +import { _noopVoid } from "../OpenTelemetry/noop/noopHelpers"; import { DisabledPropertyName } from "./Constants"; import { _throwInternal, _warnToConsole } from "./DiagnosticLogger"; import { getLocation, isBeaconsSupported, isFetchSupported, isXhrSupported, useXDomainRequest } from "./EnvUtils"; @@ -30,10 +31,8 @@ declare var XDomainRequest: { /** - * This Internal component - * Manager SendPost functions - * SendPostManger - * @internal for internal use only + * Manager for SendPost functions + * @since 3.0.0 */ export class SenderPostManager { @@ -650,7 +649,7 @@ export class SenderPostManager { }; - xdr.onprogress = () => { }; + xdr.onprogress = _noopVoid; // XDomainRequest requires the same protocol as the hosting page. // If the protocol doesn't match, we can't send the telemetry :(. diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts b/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts index bb2ec6088..7d1383bbb 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts @@ -3,17 +3,17 @@ import { arrForEach, isFunction, objDefineProps } from "@nevware21/ts-utils"; import { IAppInsightsCore } from "../JavaScriptSDK.Interfaces/IAppInsightsCore"; -import { IDistributedTraceContext } from "../JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { IDistributedTraceContext, IDistributedTraceInit } from "../JavaScriptSDK.Interfaces/IDistributedTraceContext"; import { IProcessTelemetryContext, IProcessTelemetryUnloadContext } from "../JavaScriptSDK.Interfaces/IProcessTelemetryContext"; import { IPlugin, ITelemetryPlugin } from "../JavaScriptSDK.Interfaces/ITelemetryPlugin"; import { ITelemetryPluginChain } from "../JavaScriptSDK.Interfaces/ITelemetryPluginChain"; import { ITelemetryUnloadState } from "../JavaScriptSDK.Interfaces/ITelemetryUnloadState"; import { IUnloadableComponent } from "../JavaScriptSDK.Interfaces/IUnloadableComponent"; import { IW3cTraceState } from "../JavaScriptSDK.Interfaces/IW3cTraceState"; -import { IOTelSpanContext } from "../OpenTelemetry/interfaces/trace/IOTelSpanContext"; import { generateW3CId } from "./CoreUtils"; import { createElmNodeData } from "./DataCacheHelper"; import { getLocation } from "./EnvUtils"; +import { setProtoTypeName } from "./HelperFuncs"; import { STR_CORE, STR_EMPTY, STR_PRIORITY, STR_PROCESS_TELEMETRY, UNDEFINED_VALUE } from "./InternalConstants"; import { isValidSpanId, isValidTraceId } from "./W3cTraceParent"; import { createW3cTraceState } from "./W3cTraceState"; @@ -141,7 +141,7 @@ export function unloadComponents(components: any | IUnloadableComponent[], unloa return _doUnload(); } -function isDistributedTraceContext(obj: any): obj is IDistributedTraceContext { +export function isDistributedTraceContext(obj: any): obj is IDistributedTraceContext { return obj && isFunction(obj.getName) && isFunction(obj.getTraceId) && @@ -157,14 +157,10 @@ function isDistributedTraceContext(obj: any): obj is IDistributedTraceContext { * Creates an IDistributedTraceContext instance that ensures a valid traceId is always available. * The traceId will be inherited from the parent context if valid, otherwise a new random W3C trace ID is generated. * - * @param parent - An optional parent {@link IDistributedTraceContext} or {@link IOTelSpanContext} to inherit - * trace context values from. If provided, the traceId and spanId will be copied from the parent if they are valid. + * @param parent - An optional parent {@link IDistributedTraceContext} to inherit trace context values from. If + * provided, the traceId and spanId will be copied from the parent if they are valid. * When the parent is an {@link IDistributedTraceContext}, it will be set as the parentCtx property to maintain * hierarchical relationships and enable parent context updates. - * When the parent is an {@link IOTelSpanContext}, the parentCtx will be null because OpenTelemetry span contexts - * are read-only data sources that don't support the same hierarchical management methods as IDistributedTraceContext. - * The core instance will create a wrapped IDistributedTraceContext instance from the IOTelSpanContext data - * to enable Application Insights distributed tracing functionality while maintaining OpenTelemetry compatibility. * * @returns A new IDistributedTraceContext instance with the following behavior: * - **traceId**: Always present - either inherited from parent (if valid) or newly generated W3C trace ID @@ -178,13 +174,10 @@ function isDistributedTraceContext(obj: any): obj is IDistributedTraceContext { * which is essential for the refactored W3C trace state implementation. The spanId may be empty until a * specific span is created, which is normal behavior for trace contexts. * - * The distinction between IDistributedTraceContext and IOTelSpanContext parents is important: - * - IDistributedTraceContext parents enable bidirectional updates and hierarchical management - * - IOTelSpanContext parents are used only for initial data extraction and OpenTelemetry compatibility */ -export function createDistributedTraceContext(parent?: IDistributedTraceContext | IOTelSpanContext): IDistributedTraceContext { +export function createDistributedTraceContext(parent?: IDistributedTraceContext | IDistributedTraceInit | undefined | null): IDistributedTraceContext { let parentCtx: IDistributedTraceContext = null; - let spanContext: IOTelSpanContext = null; + let initCtx: IDistributedTraceInit = null; let traceId = (parent && isValidTraceId(parent.traceId)) ? parent.traceId : generateW3CId(); let spanId = (parent && isValidSpanId(parent.spanId)) ? parent.spanId : STR_EMPTY; let traceFlags = parent ? parent.traceFlags : UNDEFINED_VALUE; @@ -197,7 +190,7 @@ export function createDistributedTraceContext(parent?: IDistributedTraceContext parentCtx = parent; pageName = parentCtx.getName(); } else { - spanContext = parent; + initCtx = parent; } } @@ -272,17 +265,13 @@ export function createDistributedTraceContext(parent?: IDistributedTraceContext function _getTraceState(): IW3cTraceState { if (!traceState) { - if (spanContext && spanContext.traceState) { - traceState = createW3cTraceState(spanContext.traceState.serialize() || STR_EMPTY, parentCtx ? parentCtx.traceState : undefined); - } else { - traceState = createW3cTraceState(STR_EMPTY, parentCtx ? parentCtx.traceState : undefined); - } + traceState = createW3cTraceState(STR_EMPTY, parentCtx ? parentCtx.traceState : (initCtx ? initCtx.traceState : undefined)); } return traceState; } - let traceCtx: IDistributedTraceContext = { + let traceCtx: IDistributedTraceContext = setProtoTypeName({ getName: _getName, setName: _setPageNameFn(true), getTraceId: _getTraceId, @@ -297,7 +286,7 @@ export function createDistributedTraceContext(parent?: IDistributedTraceContext traceState, isRemote, pageName - }; + }, "DistributedTraceContext"); return objDefineProps(traceCtx, { pageName: { @@ -326,6 +315,23 @@ export function createDistributedTraceContext(parent?: IDistributedTraceContext }, parentCtx: { g: () => parentCtx + }, + _parent: { + g: () => { + let result: any; + if (parentCtx) { + result = { + t: "traceCtx", + v: parentCtx + }; + } else if(initCtx) { + result = { + t: "initCtx", + v: initCtx + } + } + return result; + } } }); } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts b/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts index bbacad69f..dc70a29f0 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts @@ -2,11 +2,12 @@ // Licensed under the MIT License. import { - ICachedValue, WellKnownSymbols, arrForEach, arrIndexOf, createCachedValue, createDeferredCachedValue, getKnownSymbol, isArray, - isFunction, isNullOrUndefined, isString, objDefine, objDefineProps, safe, strSplit + ICachedValue, arrForEach, arrIndexOf, createCachedValue, isArray, isFunction, isNullOrUndefined, isString, objDefineProps, + safeGetDeferred, strSplit } from "@nevware21/ts-utils"; import { IW3cTraceState } from "../JavaScriptSDK.Interfaces/IW3cTraceState"; import { findMetaTags, findNamedServerTimings } from "./EnvUtils"; +import { setObjStringTag } from "./HelperFuncs"; import { STR_EMPTY } from "./InternalConstants"; const MAX_TRACE_STATE_MEMBERS = 32; @@ -245,7 +246,7 @@ export function isW3cTraceState(value: any): value is IW3cTraceState { * @returns - A new distributed trace state instance */ export function createW3cTraceState(value?: string | null, parent?: IW3cTraceState | null): IW3cTraceState { - let cachedItems: ICachedValue = createDeferredCachedValue(() => safe(_parseTraceStateList, [value || STR_EMPTY]).v || []); + let cachedItems: ICachedValue = safeGetDeferred(_parseTraceStateList, [], [value || STR_EMPTY]); function _get(key: string): string | undefined { let value: string | undefined; @@ -387,8 +388,7 @@ export function createW3cTraceState(value?: string | null, parent?: IW3cTraceSta } }); - - objDefine(traceStateList, getKnownSymbol(WellKnownSymbols.toStringTag), { g: _toString }); + setObjStringTag(traceStateList, _toString); return traceStateList; } diff --git a/shared/AppInsightsCore/src/OpenTelemetry/attribute/IAttributeContainer.ts b/shared/AppInsightsCore/src/OpenTelemetry/attribute/IAttributeContainer.ts new file mode 100644 index 000000000..9cbd6eaf3 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/attribute/IAttributeContainer.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IUnloadHook } from "../../JavaScriptSDK.Interfaces/IUnloadHook"; +import { eAttributeChangeOp } from "../enums/eAttributeChangeOp"; +import { IOTelAttributes, OTelAttributeValue } from "../interfaces/IOTelAttributes"; + +/** + * Identifies the source of an attribute value in iterator operations + * @since 3.4.0 + */ +export const enum eAttributeFilter { + /** + * The attribute exists local to the current container instance + */ + Local = 0, + + /** + * The attribute does not exist locally and is inherited from a parent container or attributes object + */ + Inherited = 1, + + /** + * The attribute exists or has been deleted locally (only) to the current container instance + */ + LocalOrDeleted = 2 +} + +export type AttributeFilter = number | eAttributeFilter; + +/** + * Information about what changed in an attribute container + */ +export interface IAttributeChangeInfo { + /** + * The Id of the container that is initiated the change (not the immediate sender -- which is always the parent) + * As children only receive listener notifications from their parent in reaction to both changes + * they make and any changes they receive from their parent + */ + frm: string; + + /** + * Operation type that occurred + */ + op: eAttributeChangeOp; + + /** + * The key that was changed (only present for 'set' operations) + */ + k?: string; + + /** + * The old value (only present for 'set' operations when replacing existing value) + */ + prev?: V; + + /** + * The new value (only present for 'set' operations) + */ + val?: V; +} + +/** + * Interface for an attribute container + * @since 3.4.0 + */ +export interface IAttributeContainer { + /** + * Unique identifier for the attribute container + */ + readonly id: string; + + /** + * The number of attributes that have been set + * @returns The number of attributes that have been set + */ + readonly size: number; + + /** + * The number of attributes that were dropped due to the attribute limit being reached + * @returns The number of attributes that were dropped due to the attribute limit being reached + */ + readonly droppedAttributes: number; + + /** + * Return a snapshot of the current attributes, including inherited ones. + * This value is read-only and reflects the state of the attributes at the time of access, + * and the returned instance will not change if any attributes are modified later, you will need + * to access the attributes property again to get the latest state. + * + * Note: As this causes a snapshot to be taken, it is an expensive operation as it enumerates all + * attributes, so you SHOULD use this property sparingly. + * @returns A read-only snapshot of the current attributes + */ + readonly attributes: IOTelAttributes; + + /** + * Clear all existing attributes from the container, this will also remove any inherited attributes + * from this instance only (it will not change the inherited attributes / container(s)) + */ + clear: () => void; + + /** + * Get the value of an attribute by key + * @param key - The attribute key to retrieve + * @param source - Optional filter to only check attributes from a specific source (Local or Inherited) + * @returns The attribute value if found, undefined otherwise + */ + get: (key: string, source?: eAttributeFilter) => V | undefined; + + /** + * Check if an attribute exists by key + * @param key - The attribute key to check + * @param source - Optional filter to only check attributes from a specific source (Local or Inherited) + * @returns True if the attribute exists, false otherwise + */ + has: (key: string, source?: eAttributeFilter) => boolean; + + /** + * Set the value of an attribute by key on this instance. + * @param key - The attribute key to set + * @param value - The value to assign to the named attribute + * @returns true if the value was successfully set / replaced + */ + set: (key: string, value: V) => boolean; + + /** + * Delete an existing attribute, if the key doesn't exist this will return false. If the key does + * exist then it will be removed from this instance and any inherited value will be hidden (even if + * the inherited value changes) + * @param key - The attribute key to delete + * @returns True if the attribute was deleted, false if it didn't exist (which includes if it has already been deleted) + */ + del: (key: string) => boolean; + + /** + * The keys() method returns a new iterator object that contains the existing keys for each element + * in this attribute container. It will return all locally set keys first and then the inherited keys. + * When a key exists in both the local and inherited attributes, only the local key will be returned. + * If the key has been deleted locally, it will not be included in the iterator. + * @returns An iterator over the keys of the attribute container + */ + keys: () => Iterator; + + /** + * The entries() method of returns a new iterator object that contains the [key, value, source?] tuples for + * each attribute, it returns all existing attributes of this instance including all inherited ones. If the + * same key exists in both the local and inherited attributes, only the first (non-deleted) tuple will be returned. + * If the key has been deleted, it will not be included in the iterator. + * + * The source value of the tuple identifies the origin of the attribute (Local or Inherited). + * @returns An iterator over the entries of the attribute container + */ + entries: () => Iterator<[string, V, eAttributeFilter]>; + + /** + * The forEach() method of executes a provided function once per each key/value pair in this attribute container, + * it will process all local attributes first, then the inherited attributes. If the same key exists in both the + * local and inherited attributes, only the first (non-deleted) key/value pair will be processed. + * If a key has been deleted, it will not be included in the set of processed key/value pairs. + * @param callback - The function to execute for each key/value pair + * @param thisArg - Optional value to use as `this` when executing `callback` + */ + forEach: (callback: (key: string, value: V, source?: eAttributeFilter) => void, thisArg?: any) => void; + + /** + * The values() method returns a new iterator instance that contains the values for each element in this + * attribute container. It will return all locally set values first and then the inherited values. If the + * same key is present in both the local or inherited attributes only the first (non-deleted) value will be + * returned. If a key has been deleted, it will not be included in the iterator. + * @returns An iterator over the values of the attribute container + */ + values: () => Iterator; + + /** + * Register a callback listener for any attribute changes, this will include local and inherited changes. + * @param callback - Function to be called when attributes change, receives change information + * @returns IUnloadHook instance with rm() function to remove this listener, once called it will never be invoked again + */ + listen: (callback: (changeInfo: IAttributeChangeInfo) => void) => IUnloadHook; + + /** + * Create a child attribute container that inherits from this one, optionally taking a snapshot + * so that any future changes to the parent container do not affect the child container. + * The child will use all of the configuration from the parent container. + * @param name - Optional name for the child container + * @param snapshot - If true, the child container will be a snapshot of the current state + * @returns A new attribute container instance + */ + child: (name?: string, snapshot?: boolean) => IAttributeContainer +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/attribute/SemanticConventions.ts b/shared/AppInsightsCore/src/OpenTelemetry/attribute/SemanticConventions.ts new file mode 100644 index 000000000..832a92b37 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/attribute/SemanticConventions.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const MICROSOFT_APPLICATIONINSIGHTS_NAME = (/* #__PURE__ */"Microsoft.ApplicationInsights."); +const MICROSOFT_DOT = (/* #__PURE__ */"microsoft."); +const CLIENT_DOT = (/*#__PURE__*/ "client."); +const HTTP_DOT = (/*#__PURE__*/ "http."); +const NET_DOT = (/*#__PURE__*/ "net."); +const PEER_DOT = (/*#__PURE__*/ "peer."); +const EXCEPTION_DOT = (/*#__PURE__*/ "exception."); +const ENDUSER_DOT = (/*#__PURE__*/ "enduser."); +const URL_DOT = (/*#__PURE__*/ "url."); +const DB_DOT = (/*#__PURE__*/ "db."); +const NETWORK_DOT = (/*#__PURE__*/ "network."); + +export const AzureMonitorSampleRate = (/* #__PURE__*/ MICROSOFT_DOT + "sample_rate"); +export const ApplicationInsightsCustomEventName = (/* #__PURE__*/ MICROSOFT_DOT + "custom_event.name"); +export const MicrosoftClientIp = (/* #__PURE__*/ MICROSOFT_DOT + CLIENT_DOT + "ip"); + +export const ApplicationInsightsMessageName = (/* #__PURE__*/ MICROSOFT_APPLICATIONINSIGHTS_NAME + "Message"); +export const ApplicationInsightsExceptionName = (/* #__PURE__*/ MICROSOFT_APPLICATIONINSIGHTS_NAME + "Exception"); +export const ApplicationInsightsPageViewName = (/* #__PURE__*/ MICROSOFT_APPLICATIONINSIGHTS_NAME + "PageView"); +export const ApplicationInsightsAvailabilityName = (/* #__PURE__*/ MICROSOFT_APPLICATIONINSIGHTS_NAME + "Availability"); +export const ApplicationInsightsEventName = (/* #__PURE__*/ MICROSOFT_APPLICATIONINSIGHTS_NAME + "Event"); + +export const ApplicationInsightsBaseType = (/* #__PURE__*/ "_MS.baseType"); +export const ApplicationInsightsMessageBaseType = (/* #__PURE__*/ "MessageData"); +export const ApplicationInsightsExceptionBaseType = (/* #__PURE__*/ "ExceptionData"); +export const ApplicationInsightsPageViewBaseType = (/* #__PURE__*/ "PageViewData"); +export const ApplicationInsightsAvailabilityBaseType = (/* #__PURE__*/ "AvailabilityData"); +export const ApplicationInsightsEventBaseType = (/* #__PURE__*/ "EventData"); + +export const ATTR_ENDUSER_ID = (/* #__PURE__*/ENDUSER_DOT + "id"); +export const ATTR_ENDUSER_PSEUDO_ID = (/* #__PURE__*/ENDUSER_DOT + "pseudo_id"); +export const ATTR_HTTP_ROUTE = (/* #__PURE__*/HTTP_DOT + "route"); + +export const SEMATTRS_NET_PEER_IP = (/*#__PURE__*/ NET_DOT + PEER_DOT + "ip"); +export const SEMATTRS_NET_PEER_NAME = (/*#__PURE__*/ NET_DOT + PEER_DOT + "name"); +export const SEMATTRS_NET_HOST_IP = (/*#__PURE__*/ NET_DOT + "host.ip"); +export const SEMATTRS_PEER_SERVICE = (/*#__PURE__*/ PEER_DOT + "service"); +export const SEMATTRS_HTTP_USER_AGENT = (/*#__PURE__*/ HTTP_DOT + "user_agent"); +export const SEMATTRS_HTTP_METHOD = (/*#__PURE__*/ HTTP_DOT + "method"); +export const SEMATTRS_HTTP_URL = (/*#__PURE__*/ HTTP_DOT + "url"); +export const SEMATTRS_HTTP_STATUS_CODE = (/*#__PURE__*/ HTTP_DOT + "status_code"); +export const SEMATTRS_HTTP_ROUTE = (/*#__PURE__*/ HTTP_DOT + "route"); +export const SEMATTRS_HTTP_HOST = (/*#__PURE__*/ HTTP_DOT + "host"); +export const SEMATTRS_DB_SYSTEM = (/*#__PURE__*/ DB_DOT + "system"); +export const SEMATTRS_DB_STATEMENT = (/*#__PURE__*/ DB_DOT + "statement"); +export const SEMATTRS_DB_OPERATION = (/*#__PURE__*/ DB_DOT + "operation"); +export const SEMATTRS_DB_NAME = (/*#__PURE__*/ DB_DOT + "name"); +export const SEMATTRS_RPC_SYSTEM = (/*#__PURE__*/ "rpc.system"); +export const SEMATTRS_RPC_GRPC_STATUS_CODE = (/*#__PURE__*/ "rpc.grpc.status_code"); +export const SEMATTRS_EXCEPTION_TYPE = (/*#__PURE__*/ EXCEPTION_DOT + "type"); +export const SEMATTRS_EXCEPTION_MESSAGE = (/*#__PURE__*/ EXCEPTION_DOT + "message"); +export const SEMATTRS_EXCEPTION_STACKTRACE = (/*#__PURE__*/ EXCEPTION_DOT + "stacktrace"); +export const SEMATTRS_HTTP_SCHEME = (/*#__PURE__*/ HTTP_DOT + "scheme"); +export const SEMATTRS_HTTP_TARGET = (/*#__PURE__*/ HTTP_DOT + "target"); +export const SEMATTRS_HTTP_FLAVOR = (/*#__PURE__*/ HTTP_DOT + "flavor"); +export const SEMATTRS_NET_TRANSPORT = (/*#__PURE__*/ NET_DOT + "transport"); +export const SEMATTRS_NET_HOST_NAME = (/*#__PURE__*/ NET_DOT + "host.name"); +export const SEMATTRS_NET_HOST_PORT = (/*#__PURE__*/ NET_DOT + "host.port"); +export const SEMATTRS_NET_PEER_PORT = (/*#__PURE__*/ NET_DOT + PEER_DOT + "port"); +export const SEMATTRS_HTTP_CLIENT_IP = (/*#__PURE__*/ HTTP_DOT + "client_ip"); +export const SEMATTRS_ENDUSER_ID = (/*#__PURE__*/ ENDUSER_DOT + "id"); + +export const ATTR_CLIENT_ADDRESS = (/*#__PURE__*/ CLIENT_DOT + "address"); +export const ATTR_CLIENT_PORT = (/*#__PURE__*/ CLIENT_DOT + "port"); +export const ATTR_SERVER_ADDRESS = (/*#__PURE__*/ "server.address"); +export const ATTR_SERVER_PORT = (/*#__PURE__*/ "server.port"); +export const ATTR_URL_FULL = (/*#__PURE__*/ URL_DOT + "full"); +export const ATTR_URL_PATH = (/*#__PURE__*/ URL_DOT + "path"); +export const ATTR_URL_QUERY = (/*#__PURE__*/ URL_DOT + "query"); +export const ATTR_URL_SCHEME = (/*#__PURE__*/ URL_DOT + "scheme"); +export const ATTR_ERROR_TYPE = (/*#__PURE__*/ "error.type"); +export const ATTR_NETWORK_LOCAL_ADDRESS = (/*#__PURE__*/ NETWORK_DOT + "local.address"); +export const ATTR_NETWORK_LOCAL_PORT = (/*#__PURE__*/ NETWORK_DOT + "local.port"); +export const ATTR_NETWORK_PROTOCOL_NAME = (/*#__PURE__*/ NETWORK_DOT + "protocol.name"); +export const ATTR_NETWORK_PEER_ADDRESS = (/*#__PURE__*/ NETWORK_DOT + PEER_DOT + "address"); +export const ATTR_NETWORK_PEER_PORT = (/*#__PURE__*/ NETWORK_DOT + PEER_DOT + "port"); +export const ATTR_NETWORK_PROTOCOL_VERSION = (/*#__PURE__*/ NETWORK_DOT + "protocol.version"); +export const ATTR_NETWORK_TRANSPORT = (/*#__PURE__*/ NETWORK_DOT + "transport"); +export const ATTR_USER_AGENT_ORIGINAL = (/*#__PURE__*/ "user_agent.original"); +export const ATTR_HTTP_REQUEST_METHOD = (/*#__PURE__*/ HTTP_DOT + "request.method"); +export const ATTR_HTTP_RESPONSE_STATUS_CODE = (/*#__PURE__*/ HTTP_DOT + "response.status_code"); +export const ATTR_EXCEPTION_TYPE = (/*#__PURE__*/ EXCEPTION_DOT + "type"); +export const ATTR_EXCEPTION_MESSAGE = (/*#__PURE__*/ EXCEPTION_DOT + "message"); +export const ATTR_EXCEPTION_STACKTRACE = (/*#__PURE__*/ EXCEPTION_DOT + "stacktrace"); +export const EXP_ATTR_ENDUSER_ID = (/*#__PURE__*/ ENDUSER_DOT + "id"); +export const EXP_ATTR_ENDUSER_PSEUDO_ID = (/*#__PURE__*/ ENDUSER_DOT + "pseudo_id"); +export const EXP_ATTR_SYNTHETIC_TYPE = (/*#__PURE__*/ "synthetic.type"); + + +export const DBSYSTEMVALUES_MONGODB = (/*#__PURE__*/ "mongodb"); +export const DBSYSTEMVALUES_COSMOSDB = (/*#__PURE__*/ "cosmosdb"); +export const DBSYSTEMVALUES_MYSQL = (/*#__PURE__*/ "mysql"); +export const DBSYSTEMVALUES_POSTGRESQL = (/*#__PURE__*/ "postgresql"); +export const DBSYSTEMVALUES_REDIS = (/*#__PURE__*/ "redis"); +export const DBSYSTEMVALUES_DB2 = (/*#__PURE__*/ "db2"); +export const DBSYSTEMVALUES_DERBY = (/*#__PURE__*/ "derby"); +export const DBSYSTEMVALUES_MARIADB = (/*#__PURE__*/ "mariadb"); +export const DBSYSTEMVALUES_MSSQL = (/*#__PURE__*/ "mssql"); +export const DBSYSTEMVALUES_ORACLE = (/*#__PURE__*/ "oracle"); +export const DBSYSTEMVALUES_SQLITE = (/*#__PURE__*/ "sqlite"); +export const DBSYSTEMVALUES_OTHER_SQL = (/*#__PURE__*/ "other_sql"); +export const DBSYSTEMVALUES_HSQLDB = (/*#__PURE__*/ "hsqldb"); +export const DBSYSTEMVALUES_H2 = (/*#__PURE__*/ "h2"); diff --git a/shared/AppInsightsCore/src/OpenTelemetry/attribute/attributeContainer.ts b/shared/AppInsightsCore/src/OpenTelemetry/attribute/attributeContainer.ts new file mode 100644 index 000000000..b6101bd37 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/attribute/attributeContainer.ts @@ -0,0 +1,1134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + CreateIteratorContext, ICachedValue, arrForEach, arrIndexOf, createCachedValue, createIterator, getLength, isFunction, isObject, + isUndefined, iterForOf, objCreate, objDefine, objDefineProps, objForEachKey, objIs, objKeys, safe, strSplit +} from "@nevware21/ts-utils"; +import { IUnloadHook } from "../../JavaScriptSDK.Interfaces/IUnloadHook"; +import { STR_EMPTY, UNDEFINED_VALUE } from "../../JavaScriptSDK/InternalConstants"; +import { eAttributeChangeOp } from "../enums/eAttributeChangeOp"; +import { handleAttribError } from "../helpers/handleErrors"; +import { IOTelAttributes, OTelAttributeValue } from "../interfaces/IOTelAttributes"; +import { IOTelAttributeLimits } from "../interfaces/config/IOTelAttributeLimits"; +import { IOTelConfig } from "../interfaces/config/IOTelConfig"; +import { ITraceCfg } from "../interfaces/config/ITraceCfg"; +import { IAttributeChangeInfo, IAttributeContainer, eAttributeFilter } from "./IAttributeContainer"; + +let _inheritedKey = "~[[inherited]]"; +let _deletedKey = "~[[deleted]]"; +let _containerId = 0; + +type IAttributeBranch = { [key: string]: IAttributeNode }; + +const enum eAttributeSource { + Local = 0, + + Inherited = 1 +} + +interface _AttributeFindDetail { + /** + * The hosting container + */ + c: IAttributeContainer; + + /** + * The found key + */ + k: string; + + /** + * The local attribute node + */ + n: IAttributeNode; + + /** + * The found value + */ + v: V; + + /** + * The attribute source (local or inherited) + */ + s: eAttributeSource; + + /** + * Identifies if this key should be considered to "exist" (is if present) + */ + e: boolean; +} + +interface IAttributeNode { + /** + * The value of the attribute tree + */ + v?: V; + + /** + * Identifies that this is a leaf node in the attribute tree + */ + n?: IAttributeBranch; + + /** + * Identifies that this node has been locally deleted + */ + d?: boolean; +} + +interface IAttributeIteratorState { + p: string; + n: IAttributeBranch; + k: string[]; + i: number; +} + +const enum AddAttributeResult { + Success = 0, + MaxAttribsExceeded = 1, + BranchNodeExists = 2, + LeafNodeExists = 3, + EmptyKey = 4 +} + +interface IAddAttributeDetails { + r: AddAttributeResult; // result + a?: boolean; // added (true if new attribute was added, false if existing was replaced) + p?: V; // prev (the previous value if one existed) +} + +function _noOpFunc() { + // No-op function +} + +function _addValue(container: IAttributeContainer, target: IAttributeBranch, theKey: string, value: V, maxAttribs: number, cfg: IOTelConfig): IAddAttributeDetails { + let errorHandlers = cfg.errorHandlers || {}; + if (theKey && getLength(theKey) > 0) { + let key = theKey.split("."); + let parts: string[] = []; + let keyIndex = 0; + + let keyLen = getLength(key); + while (keyIndex < keyLen) { + let part = key[keyIndex]; + parts.push(part); + + if (keyIndex === keyLen - 1) { + // last part + if (target[part] && target[part].n) { + // This node already exists as a branch node + handleAttribError(errorHandlers, "Attribute key [" + parts.join(".") + "] already exists as a branch node", theKey, value); + return { r: AddAttributeResult.BranchNodeExists }; + } + + if (!target[part] || target[part].d) { + // Node doesn't exist or was deleted + if (container.size >= maxAttribs) { + // If the key is not already present, we have exceeded the limit + // But if it does exist in the hierarchy, we can replace it locally + if (!container.has(theKey)) { + handleAttribError(errorHandlers, "Maximum allowed attributes exceeded [" + maxAttribs + "]", theKey, value); + return { r: AddAttributeResult.MaxAttribsExceeded }; + } + } + + // Add new leaf node or restore deleted node + target[part] = { + v: value + }; + return { r: AddAttributeResult.Success, a: true }; + } else { + // replace the value - capture the previous value + let previousValue = target[part].v; + target[part].v = value; + target[part].d = false; // Clear any deleted flag when setting value + return { r: AddAttributeResult.Success, a: false, p: previousValue }; + } + } + + if (!target[part]) { + target[part] = { + n: {} + }; + } + + if (!target[part].n) { + handleAttribError(errorHandlers, "Attribute key [" + parts.join(".") + "] already exists as a leaf node", theKey, value); + return { r: AddAttributeResult.LeafNodeExists }; + } + + target = target[part].n; + keyIndex++; + } + } + + handleAttribError(errorHandlers, "Attribute key is empty", theKey, value); + return { r: AddAttributeResult.EmptyKey }; +} + +/** + * Delete a specific attribute from the tree + * @param target - The target branch to delete from + * @param key - The key parts to delete + * @returns true if the attribute was deleted, false if it didn't exist + */ +function _deleteValue(target: IAttributeBranch, key: string[]): { d: boolean; p?: V } { + if (key && getLength(key) > 0) { + let keyIndex = 0; + let keyLen = getLength(key); + + // Navigate to the parent of the target node, creating path if needed + while (keyIndex < keyLen - 1) { + let part = key[keyIndex++]; + + if (!target[part]) { + target[part] = { + n: {} + }; + } + + if (!target[part].n) { + // Path is blocked by a leaf node, can't delete + return { d: false }; + } + + target = target[part].n; + } + + // Now we're at the parent of the target node + let lastPart = key[keyLen - 1]; + + if (target[lastPart] && !target[lastPart].d && !target[lastPart].n) { + // Node exists, is not already deleted, and is a leaf - delete it + let prev = target[lastPart].v; + target[lastPart].d = true; // Mark as deleted + target[lastPart].v = UNDEFINED_VALUE; // Clear the value + return { d: true, p: prev }; + } else if (!target[lastPart] || !target[lastPart].d) { + // Node doesn't exist or is not already deleted - create a deleted marker + // This is important for tracking inherited keys that are locally deleted + // and for the edge case where inherited keys might be added later + target[lastPart] = { + d: true // Mark as deleted without a value + }; + return { d: true }; + } + } + + return { d: false }; +} + +/** + * Find a local specific node (including if it's been deleted) in the attribute tree, or if it exists in an inherited source + * @param container - The target attribute container to search in + * @param key - The key to search for + * @param filter - The filter to apply + * @param cb - The callback function to execute when the node is found + * @param nodes - The attribute branches to search in + * @param inheritContainer - The inherited attribute container + * @param inheritAttribObj - The inherited attribute object + * @param otelCfg - Optional OpenTelemetry configuration + * @returns The result of the callback function + */ +function _findDetail(container: IAttributeContainer, key: string, filter: eAttributeFilter | undefined, cb: (detail?: _AttributeFindDetail) => T, nodes: IAttributeBranch, inheritContainer?: IAttributeContainer | null, inheritAttribObj?: IOTelAttributes | null, otelCfg?: IOTelConfig) { + let theDetail: _AttributeFindDetail; + + // Find the local node + if (key && getLength(key) > 0) { + let theNode: IAttributeNode | undefined; + let target = nodes; + let keys = strSplit(key, "."); + let keysLen = getLength(keys); + let idx = 0; + + if (filter == eAttributeFilter.Local || filter === eAttributeFilter.LocalOrDeleted || isUndefined(filter)) { + // Find any local node (if it exists) + while (target && idx < keysLen) { + let part = keys[idx++]; + let node = target[part]; + if (node && idx >= keysLen) { + // last part + theNode = node; + break; + } + + if (!node || !node.n) { + // TODO - support wildcard(s) (when no node is found), likely need to refactor this to support multiple keys + break; + } + + target = node.n; + } + + // So if the caller didn't specify a filter then always return the found "value" (which will be undefined for a deleted key) + // If they have specified a filter, then we need to respect that filter where Local and LocalOrDeleted are treated the same + if (theNode) { + theDetail = { + c: container, + k: key, + n: theNode, + v: theNode.d ? UNDEFINED_VALUE : theNode.v, + s: eAttributeSource.Local, + e: !theNode.d + }; + } + } + + // So we get here if + // - We didn't find a node (no local overrides) + // - The filter doesn't match (undefined, Local, LocalOrDeleted), so we didn't look for a node + // So we just need to check if the filter is undefined or inherited and if so we are free + // to look in the inherited sources + if (!theDetail && (filter === eAttributeFilter.Inherited || isUndefined(filter))) { + const inheritedValue = _getInheritedValue(key, inheritContainer, inheritAttribObj); + if (inheritedValue) { + theDetail = { + c: container, + k: key, + n: null, + v: inheritedValue.v as V, + s: eAttributeSource.Inherited, + e: true + }; + } + } + } + + return cb(theDetail); +} + +function _size(target: IAttributeBranch, inheritAttrib?: IOTelAttributes, inheritContainer?: IAttributeContainer): number { + let count = 0; + let seenKeys: string[] = []; + + // Use the iterator to count all unique attributes including inheritance + let iter = _iterator(target, (prefix, key, _node) => { + return prefix + key; + }, undefined, inheritContainer || inheritAttrib); + + iterForOf(iter, (key) => { + if (arrIndexOf(seenKeys, key) === -1) { + seenKeys.push(key); + count++; + } + }); + + return count; +} + +function _getStackEntry(theNode: IAttributeBranch, prefix: string): IAttributeIteratorState | undefined { + let entry: IAttributeIteratorState | undefined; + + if (theNode) { + let allKeys: string[] = objKeys(theNode); + if (getLength(allKeys) > 0) { + entry = { + p: prefix, + n: theNode, + k: allKeys, + i: -1 + }; + } + } + + return entry; +} + +function _iterator(target: IAttributeBranch, cb: (prefix: string, key: string, node: IAttributeNode, source: eAttributeFilter) => T, otelCfg?: IOTelConfig, parentAttribs?: IOTelAttributes | IAttributeContainer, inclDeleted?: boolean): Iterator { + let stack: IAttributeIteratorState[] = []; + let visitedKeys: string[] | undefined = parentAttribs ? [] : UNDEFINED_VALUE; + let inheritState: IAttributeIteratorState; + let inheritContainerIter: Iterator<[string, any, eAttributeFilter]>; + let current = _getStackEntry(target, STR_EMPTY); // Start with the root branch + let inheritContainer: IAttributeContainer | undefined; + let inheritAttribs: IOTelAttributes | undefined; + + // Used as the initializer for the iterator and the flag to indicate that the iterator is done + let ctx: CreateIteratorContext | null = { + v: undefined, + n: _moveNext + }; + + if (parentAttribs) { + if (isAttributeContainer(parentAttribs)) { + inheritContainer = parentAttribs; + } else if (isObject(parentAttribs)) { + inheritAttribs = parentAttribs; + } + } + + function _moveNext(): boolean { + let thePrefix = STR_EMPTY; + let theKey: string | undefined; + let theNode: IAttributeNode | undefined; + let theSource = eAttributeFilter.Local; // Default to local + + // Process entries from the current node + while (current) { + current.i++; + if (current.i < getLength(current.k)) { + // We have at least 1 key + let key = current.k[current.i]; + // The key is "real" (we don't support null or undefined keys) + let node = current.n[key]; + if (node) { + if (node.n) { + // We are at a branch node, so add the current node to the stack so we can continue traversing it later + stack.push(current); + current = _getStackEntry(node.n, current.p + key + "."); + + // If the branch is empty, continue with the next item from stack + if (!current) { + current = stack.pop(); + } + } else { + // Leaf node + let fullKey = current.p + key; + if (visitedKeys && arrIndexOf(visitedKeys, fullKey) === -1) { + visitedKeys.push(fullKey); + } + + // If we are not deleted, then return this key + if (!node.d || inclDeleted) { + // Leaf node with value - not deleted + thePrefix = current.p; + theKey = key; + theNode = node; + theSource = node.d ? eAttributeFilter.LocalOrDeleted : eAttributeFilter.Local; + break; + } + } + } + } else { + // No more keys to process at this level + current = stack.pop(); + } + } + + // We get here only after we have processed all keys of the current instance + // Switch to processing inherited attributes if we have them + if (!theNode && inheritAttribs) { + if (!inheritState) { + // Lazy initialize the inherited state + inheritState = { + p: STR_EMPTY, + n: UNDEFINED_VALUE, + k: objKeys(inheritAttribs), + i: -1 + }; + } + + // Process inherited attributes that haven't been overridden or deleted + while (inheritState.i < getLength(inheritState.k) - 1) { + inheritState.i++; + let key = inheritState.k[inheritState.i]; + if (arrIndexOf(visitedKeys, key) === -1) { + visitedKeys.push(key); + theKey = key; + theNode = { v: inheritAttribs[key] as V }; + theSource = eAttributeFilter.Inherited; + break; + } + } + } + + // Process inherited container if we have one and no other node was found + if (!theNode && inheritContainer) { + if (!inheritContainerIter) { + // Initialize the container's iterator only once - this will include the full inheritance chain + inheritContainerIter = inheritContainer.entries(); + } + + // Use the container's iterator to get next entry + iterForOf(inheritContainerIter, (entry) => { + let key = entry[0]; + if (arrIndexOf(visitedKeys, key) === -1) { + visitedKeys.push(key); + theKey = key; + theNode = { v: entry[1] as V }; + theSource = eAttributeFilter.Inherited; // Always return Inherited for inherited container entries + return -1; + } + }); + } + + if (theNode) { + ctx.v = cb(thePrefix, theKey, theNode, theSource); + } + + return !theNode; + } + + return createIterator(ctx); +} + +function _generateAttributes(container: IAttributeContainer, target: IAttributeBranch, otelCfg?: IOTelConfig, inheritAttrib?: IOTelAttributes | IAttributeContainer, showTree?: boolean): IOTelAttributes { + // TODO: Look at making this return a proxy instead of a new object + // This should be more effecient as it would make the property lookups lazy + let attribs = objCreate(null) as { [key: string]: V | undefined }; + let deletedKeys: { [key: string]: V | undefined }; + + if (showTree) { + // Add Node Id + objDefine(attribs, "#id", { + v: container.id, + w: false + }); + } + + // Use the iterator properly - collect the key-value pairs + let iter = _iterator(target, (prefix, key, node, source) => { + let name = prefix + key; + return { n: name, v: node.v as V, d: node.d, s: source }; + }, otelCfg, showTree ? null : inheritAttrib, showTree ? eAttributeFilter.LocalOrDeleted : UNDEFINED_VALUE); + + iterForOf(iter, (entry) => { + let theNode = attribs; + let theName = entry.n; + + if (entry.d) { + if (!deletedKeys) { + // Create separate objects for inherited and deleted attributes + deletedKeys = objCreate(null); + objDefine(attribs, _deletedKey, { + v: deletedKeys, + w: false + }); + } + + theNode = deletedKeys; + } + + if (theNode && !(theName in theNode)) { + theNode[theName] = entry.v; + } + }); + + if (inheritAttrib && showTree) { + // Create separate objects for inherited and deleted attributes + let inheritedKeys: any; + + // Add a tree showing the inherited attributes + if (isAttributeContainer(inheritAttrib)) { + inheritedKeys = (inheritAttrib as any)._attributes; + } else { + // Create separate objects for inherited and deleted attributes + inheritedKeys = objCreate(null) as any; + objForEachKey(inheritAttrib, (key, value) => { + inheritedKeys[key] = value as V; + }); + } + + objDefine(attribs, _inheritedKey, { + v: inheritedKeys, + w: false + }); + } + + return attribs; +} + +function _notifyListeners(listeners: ({ cb: (changeInfo: IAttributeChangeInfo) => void })[], changeInfo: IAttributeChangeInfo) { + // Notify all registered listeners of changes + if (listeners) { + arrForEach(listeners, (listener) => { + safe(listener.cb, [changeInfo]); + }); + } +} + +function _getInheritedValue(key: string, inheritContainer?: IAttributeContainer | null, inheritAttribObj?: IOTelAttributes | null ): { v: V } | undefined { + // Check inherited container first + if (inheritContainer && inheritContainer.has(key)) { + return { v: inheritContainer.get(key) as V }; + } + + // Then check inherited attributes object + if (inheritAttribObj && key in inheritAttribObj) { + return { v: inheritAttribObj[key] as V }; + } +} + +function _createUnloadHook(listeners: { cb: (changeInfo: IAttributeChangeInfo) => void }[], callback: (changeInfo: IAttributeChangeInfo) => void): IUnloadHook { + let cbInst = { + cb: callback + } + + listeners.push(cbInst); + + let unloadHook = { + rm: () => { + if (listeners && cbInst) { + let index = arrIndexOf(listeners, cbInst); + if (index >= 0) { + // Remove the current listener + listeners.splice(index, 1); + } + + // Clear the cached values + cbInst.cb = null; + cbInst = null; + + // Optimization to drop all references and shortcut any future lookups + unloadHook.rm = _noOpFunc; + } + } + }; + + return unloadHook; +} + +/** + * Creates a new attribute container with only configuration. + * + * @param otelCfg - The OpenTelemetry configuration containing trace configuration and limits + * @returns A new IAttributeContainer instance with auto-generated container ID + * @since 3.4.0 + * @example + * ```typescript + * const config = { traceCfg: { generalLimits: { attributeCountLimit: 64 } } }; + * const container = createAttributeContainer(config); + * console.log(container.id); // ".0" (auto-generated) + * ``` + */ +export function createAttributeContainer(otelCfg: IOTelConfig): IAttributeContainer; + +/** + * Creates a new attribute container with configuration and a name. + * + * @param otelCfg - The OpenTelemetry configuration containing trace configuration and limits + * @param name - The name for the container (used in the container ID) + * @returns A new IAttributeContainer instance with the specified name + * @since 3.4.0 + * @example + * ```typescript + * const config = { traceCfg: { generalLimits: { attributeCountLimit: 64 } } }; + * const container = createAttributeContainer(config, "my-container"); + * console.log(container.id); // "my-container.0" + * container.set("service.name", "my-service"); + * ``` + */ +export function createAttributeContainer(otelCfg: IOTelConfig, name: string): IAttributeContainer; + +/** + * Creates a new attribute container with configuration, name, and inheritance. + * + * @param otelCfg - The OpenTelemetry configuration containing trace configuration and limits + * @param name - The name for the container (used in the container ID) + * @param inheritAttrib - Parent attributes or container to inherit from + * @returns A new IAttributeContainer instance that inherits from the specified parent + * @since 3.4.0 + * @example + * ```typescript + * const config = { traceCfg: { generalLimits: { attributeCountLimit: 64 } } }; + * const parent = { "environment": "production", "region": "us-east-1" }; + * const child = createAttributeContainer(config, "child-container", parent); + * console.log(child.get("environment")); // "production" (inherited) + * child.set("service.name", "my-service"); // local attribute + * ``` + */ +export function createAttributeContainer(otelCfg: IOTelConfig, name: string, inheritAttrib: IOTelAttributes | IAttributeContainer): IAttributeContainer; + +/** + * Creates a new attribute container with full configuration options. + * + * @param otelCfg - The OpenTelemetry configuration containing trace configuration and limits + * @param name - The name for the container (used in the container ID) + * @param inheritAttrib - Parent attributes or container to inherit from + * @param attribLimits - Specific attribute limits to override configuration defaults + * @returns A new IAttributeContainer instance with custom limits and inheritance + * @since 3.4.0 + * @example + * ```typescript + * const config = { traceCfg: { generalLimits: { attributeCountLimit: 64 } } }; + * const parent = { "environment": "production" }; + * const customLimits = { attributeCountLimit: 32, attributeValueLengthLimit: 256 }; + * const container = createAttributeContainer(config, "limited-container", parent, customLimits); + * // This container has stricter limits than the default configuration + * ``` + */ +export function createAttributeContainer(otelCfg: IOTelConfig, name: string, inheritAttrib: IOTelAttributes | IAttributeContainer, attribLimits: IOTelAttributeLimits): IAttributeContainer; + +/** + * Creates a new attribute container that provides an efficient, observable key-value storage + * for OpenTelemetry attributes with support for inheritance, limits, and change notifications. + * + * The container supports inherited attributes from parent containers or plain objects, + * enforces attribute count and value size limits, and provides efficient iteration and access patterns. + * + * @param otelCfg - The OpenTelemetry configuration containing trace configuration and limits + * @param name - Optional name for the container (used in the container ID) + * @param inheritAttrib - Optional parent attributes or container to inherit from + * @param attribLimits - Optional specific attribute limits to override configuration defaults + * @returns A new IAttributeContainer instance with the specified configuration + * @since 3.4.0 + * @example + * ```typescript + * const config = { traceCfg: { generalLimits: { attributeCountLimit: 64 } } }; + * const container = createAttributeContainer(config, "my-container"); + * container.set("service.name", "my-service"); + * + * // With inheritance + * const parent = { "environment": "production" }; + * const child = createAttributeContainer(config, "child-container", parent); + * console.log(child.get("environment")); // "production" + * ``` + */ +export function createAttributeContainer(otelCfg: IOTelConfig, name?: string | null | undefined, inheritAttrib?: IOTelAttributes | IAttributeContainer, attribLimits?: IOTelAttributeLimits): IAttributeContainer { + let traceCfg: ITraceCfg = otelCfg.traceCfg || {}; + let nodes: { [key: string]: IAttributeNode } | null = null; + let theSize: ICachedValue | null = null; + let theDropped: ICachedValue | null = null; + let limits: IOTelAttributeLimits = traceCfg.generalLimits || {}; + let maxAttribs: number = limits.attributeCountLimit || 128; + // let maxValueLen: number = limits.attributeValueLengthLimit; + let theAttributes: ICachedValue; + let localAttributes: ICachedValue; + let droppedAttribs = 0; + let inheritContainer: IAttributeContainer | null = null; + let inheritAttribObj: IOTelAttributes | null = null; + let listeners: ({ cb: (changeInfo: IAttributeChangeInfo) => void })[] | null = null; + let parentListenerHook: IUnloadHook | null = null; + let containerName: string = name || STR_EMPTY; + + if (attribLimits) { + maxAttribs = attribLimits.attributeCountLimit || maxAttribs; + // maxValueLen = attribLimits.attributeValueLengthLimit || maxValueLen; + } + + // Determine if inheritAttrib is a container or plain attributes object + if (isAttributeContainer(inheritAttrib)) { + inheritContainer = inheritAttrib; + } else if (isObject(inheritAttrib)) { + inheritAttribObj = inheritAttrib; + } + + inheritAttrib = null; + + let inheritSrc = inheritAttribObj || inheritContainer; + let container: IAttributeContainer = { + id: (containerName || STR_EMPTY) + "." + (_containerId++), + size: 0, + droppedAttributes: 0, + attributes: UNDEFINED_VALUE, + clear: () => { + // Remove parent listener if exists + if (parentListenerHook) { + parentListenerHook.rm(); + parentListenerHook = null; + } + + // Only inform children if we appear to have any keys or could possible inherit keys + if (nodes || inheritContainer || inheritAttribObj) { + // Inform any children (Synchronously) that we are about to clear all attributes + // Called prior to clearing the nodes, so that children still have full access + // to the current and inherited attributes + _notifyListeners(listeners, { frm: container.id, op: eAttributeChangeOp.Clear }); + } + + nodes = null; + theSize = null; + theDropped = null; + theAttributes = null; + localAttributes = null; + droppedAttribs = 0; + inheritSrc = null; // Clear the inherited attributes + inheritContainer = null; // Clear inherited container as well + inheritAttribObj = null; // Clear inherited attributes as well + }, + get: (key: string, source?: eAttributeFilter) => { + return _findDetail(container, key, source, (detail) => { + // Just return the value (which will include deleted ones (as undefined)if the filter is set) + return detail ? detail.v : UNDEFINED_VALUE; + }, nodes, inheritContainer, inheritAttribObj, otelCfg); + }, + has: (key: string, source?: eAttributeFilter) => { + return _findDetail(container, key, source, (detail) => { + // Note: We may still have a detail object if the key was deleted, so we need to + // - Check if the detail is considered to "exist" + // - If the source filter is LocalOrDeleted, then return that is "exists" + return detail ? (detail.e || source === eAttributeFilter.LocalOrDeleted) : false; + }, nodes, inheritContainer, inheritAttribObj, otelCfg); + }, + set: (key: string, value: any) => { + if (!nodes) { + // Lazily create a container object + nodes = objCreate(null); + } + + let addResult = _addValue(container, nodes, key, value, maxAttribs, otelCfg); + if (addResult.r === AddAttributeResult.Success) { + theSize = null; // invalidate any previously cached size + theAttributes = null; // invalidate any previously cached attributes + localAttributes = null; // invalidate any previously cached local attributes + + // Determine operation type based on whether this was a new attribute or set + let op = addResult.a ? eAttributeChangeOp.Add : eAttributeChangeOp.Set; + let prevValue = addResult.p; + + if (op === eAttributeChangeOp.Add) { + // Special case, if we just added/changed it locally we need to lookup our parents to see if we are replacing (hiding) + // a previously inherited value and if so we need to notify our children that this was a set not an add (if the value changed) + const inheritedValue = _getInheritedValue(key, inheritContainer, inheritAttribObj); + if (inheritedValue) { + op = eAttributeChangeOp.Set; + prevValue = inheritedValue.v; + } + } + + // Only notify children if the value has changed + if (!objIs(value, prevValue)) { + + // Inform any children (Synchronously) that we have "changed" a value + // Unlike clear, this is called after the change is made, so children + // will need to "handle" the change accordingly + _notifyListeners(listeners, { + frm: container.id, + op: op, + k: key, + prev: prevValue, + val: value + }); + } + } else if (addResult.r === AddAttributeResult.MaxAttribsExceeded) { + theDropped = null; + droppedAttribs++; + _notifyListeners(listeners, { + frm: container.id, + op: eAttributeChangeOp.DroppedAttributes, + k: key, + val: value + }); + } + + return addResult.r === AddAttributeResult.Success; + }, + del: (key: string) => { + + // Find the node / value for this container + return _findDetail(container, key, UNDEFINED_VALUE, (detail) => { + // Check if the key exists locally (incl marked as deleted) or is inherited + if (detail) { + // So the key exists either locally or in an inherited source + // - It may also exists but marked as deleted + if (!nodes) { + // Always create nodes structure if it doesn't exist since we need to track deleted keys + nodes = objCreate(null); + } + + // Now lets mark this key as deleted locally + let deleteResult = _deleteValue(nodes, key.split(".")); + if (deleteResult.d) { + theSize = null; // invalidate any previously cached size + theAttributes = null; // invalidate any previously cached attributes + localAttributes = null; // invalidate any previously cached local attributes + } + + // Inform any children (Synchronously) that we have deleted a value + // This is important even if the key didn't exist locally, as it existed in inheritance + // chain and we need children (like snapshot containers) to know about the deletion to + // ignore any future inherited values. + _notifyListeners(listeners, { + frm: container.id, + op: eAttributeChangeOp.Delete, + k: key, + prev: detail.v + }); + + // Return true if we successfully marked it as deleted + return deleteResult.d; + } + + return false; + }, nodes, inheritContainer, inheritAttribObj, otelCfg); + }, + keys: () => { + return _iterator(nodes, (prefix, key, node, source) => { + return prefix + key; + }, otelCfg, inheritSrc); + }, + entries: () => { + return _iterator(nodes, (prefix, key, node, source) => { + return [prefix + key, node.v, source] as [string, V, eAttributeFilter]; + }, otelCfg, inheritSrc); + }, + values: () => { + return _iterator(nodes, (_prefix, _key, node, _source) => { + return node.v; + }, otelCfg, inheritSrc); + }, + forEach: (cb: (key: string, value: V, source?: eAttributeFilter) => void) => { + let iter = _iterator(nodes, (prefix, key, node, source) => { + cb(prefix + key, node.v, source); + return true; // Return true to indicate the callback was executed + }, otelCfg, inheritSrc); + + // Iterate over the entire container + iterForOf(iter, _noOpFunc); + }, + child: (name?: string, snapshot?: boolean) => { + const childName = (name ? name : "child") + (snapshot ? "<-@[" : "<=[") + container.id + "]"; + return snapshot ? _createSnapshotContainer(otelCfg, childName, container, attribLimits) : createAttributeContainer(otelCfg, childName, container, attribLimits); + }, + listen: (callback: (changeInfo: IAttributeChangeInfo) => void): IUnloadHook => { + if (!listeners) { + listeners = []; + } + + return _createUnloadHook(listeners, callback); + } + }; + + function _listener(changeInfo: IAttributeChangeInfo) { + // Invalidate caches when parent changes + let shouldNotify = true; + + // If a parent adds a new key, then if this instance has not replaced it + // then we need to also inform our children + if (changeInfo.op === eAttributeChangeOp.Add || changeInfo.op === eAttributeChangeOp.Set || changeInfo.op === eAttributeChangeOp.Delete) { + theSize = null; + theAttributes = null; + localAttributes = null; + + // If we already have (or deleted) this key locally, then we don't need to notify children + _findDetail(container, changeInfo.k, eAttributeFilter.LocalOrDeleted, (detail) => { + // If we have a node (which also might be that it was deleted) then don't notify + // If we didn't find a node then we still need to notify our children + shouldNotify = !detail || !detail.n; + }, nodes); + } else if (changeInfo.op === eAttributeChangeOp.Clear) { + theSize = null; + theAttributes = null; + localAttributes = null; + + // If the parent clears all of it's keys, we need to inform our children + shouldNotify = true; + } else if (changeInfo.op === eAttributeChangeOp.DroppedAttributes) { + shouldNotify = true; + theDropped = null; + } + + if (shouldNotify) { + _notifyListeners(listeners, changeInfo); + } + } + + // If we have a parent container, register a listener to stay connected to parent changes + if (inheritContainer && isFunction(inheritContainer.listen)) { + parentListenerHook = inheritContainer.listen(_listener); + } + + return objDefineProps(container, { + size: { + g: () => { + if (!theSize) { + theSize = createCachedValue(_size(nodes, inheritAttribObj, inheritContainer)); + } + + return theSize.v; + } + }, + droppedAttributes: { + g: () => { + if (!theDropped) { + theDropped = createCachedValue((inheritContainer ? inheritContainer.droppedAttributes : 0) + droppedAttribs); + } + + return theDropped.v; + } + }, + attributes: { + g: () => { + if (!theAttributes) { + theAttributes = createCachedValue(_generateAttributes(container, nodes, otelCfg, inheritSrc)); + } + + return theAttributes.v; + } + }, + _attributes: { + g: () => { + if (!localAttributes) { + localAttributes = createCachedValue(_generateAttributes(container, nodes, otelCfg, inheritSrc, true)); + } + + return localAttributes.v; + } + } + }); +} + +/** + * Add all attributes from the source attributes or container to the target container. + * This function has performance and memory implications as it immediately copies all key-value pairs + * from the source to the target container, handling both plain attribute objects and other attribute + * containers. + * + * @param container - The target container to add attributes to + * @param attributes - The source attributes or container to copy from + * @since 3.4.0 + * @example + * ```typescript + * const target = createAttributeContainer(config, "target"); + * const source = { key1: "value1", key2: "value2" }; + * addAttributes(target, source); + * + * // Or from another container + * const sourceContainer = createAttributeContainer(config, "source"); + * sourceContainer.set("key3", "value3"); + * addAttributes(target, sourceContainer); + * ``` + */ +export function addAttributes(container: IAttributeContainer, attributes: IOTelAttributes | IAttributeContainer): void { + if (isAttributeContainer(attributes)) { + // Use the container's forEach for direct processing - more efficient than entries() iterator + attributes.forEach((key, value, source) => { + container.set(key, value); + }); + } else if (attributes) { + if (isFunction(attributes.entries)) { + // Handle any type that has an entries function + iterForOf((attributes as any).entries(), function(entry: [string, any]) { + container.set(entry[0], entry[1]); + }); + } else { + // Handle as plain attributes object + objForEachKey(attributes, function(key, value) { + container.set(key, value); + }); + } + } +} + +function _createSnapshotContainer(otelCfg: IOTelConfig, name: string | undefined, sourceContainer: IAttributeContainer, attribLimits?: IOTelAttributeLimits): IAttributeContainer { + let newContainer = createAttributeContainer(otelCfg, name, sourceContainer, attribLimits); + + sourceContainer.listen((changeInfo) => { + if (changeInfo.op === eAttributeChangeOp.Clear) { + // Copy all parent keys to this container if not already present + iterForOf((sourceContainer as any).entries(), function(entry: [string, any]) { + let key = entry[0]; + // If the Key has not been set or explicitly deleted from the new Container, and the source Container + // has the key (directly or inherited), then copy the value locally so we have a copy because our parent + // some grand parent is clearing their collection of values. Note ideal as if it's a grand parent or higher + // we end up duplicating the keys multiple times, but we only know about our direct ancestor + // Note: Deleting locally hides any inherited value + if (!newContainer.has(key, eAttributeFilter.LocalOrDeleted) && sourceContainer.has(key)) { + newContainer.set(key, entry[1]); + } + }); + } else if (changeInfo.op === eAttributeChangeOp.Add) { + // For add operations add), we need to ensure that when the key is not already present on the current instance + // that we hide the key so it's "not" inherited, by marking it as deleted. + let key = changeInfo.k; + if (!newContainer.has(key, eAttributeFilter.Local)) { + newContainer.del(key); + } + } else if (changeInfo.op === eAttributeChangeOp.Set || changeInfo.op === eAttributeChangeOp.Delete) { + // For set operations (change), preserve the previous value if we don't have it locally + // For delete operations, if we don't have the key locally, preserve it with the deleted value + let key = changeInfo.k; + if (!newContainer.has(key, eAttributeFilter.LocalOrDeleted)) { + newContainer.set(key, changeInfo.prev); + } + } + }); + + return newContainer; +} + +/** + * Creates a snapshot container based on the passed IOTelAttributes or IAttributeContainer. + * The returned container effectively treats the source attributes as immutable as at the time of creation, you + * may still add / update existing attributes without affecting the original source. And changes to the source + * attributes / container will not be reflected from the new snapshot container, only changes made to the returned + * container itself. + * + * Note: It implements this in a lazy manner, so changes made to the original source after the snapshot is taken will + * cause the changed attributes to be copied into this snapshot container with it's original value and cause a local + * version of the changed key (if not already been present) to be added, so when using the {@link IAttributeContainer#has} or + * {@link IAttributeContainer#get} methods, with the optional source filter as {@link eAttributeFilter.Inherited} it will + * only return attributes that were present and unchanged at the time of the snapshot. This means for those attributes you must + * use the {@link eAttributeFilter.Local} as source filter (or leave it undefined) to access the local version. + * + * It is recommended that you always use {@link IAttributeContainer} instances for better memory and performance overheads, + * for this specific function when you pass a {@link IOTelAttributes} instance it will create a copy of all present attributes + * at the point of creation (not lazily). + * + * @param otelCfg - The OpenTelemetry configuration to use for the snapshot container + * @param source - The source attributes or container to create a snapshot view from + * @param attribLimits - Optional attribute limits to apply to the snapshot container + * @returns An IAttributeContainer instance that will preserves the attributes and values from the source attributes / container + * at creation time. The snapshot container will be named "snapshot(xxxx)" where xxxx is the source container ID, or "snapshot(...)" for non-container sources. + * @since 3.4.0 + * @example + * ```typescript + * // Immediate copy for plain attributes + * const attrs = { key1: "value1", key2: "value2" }; + * const snapshot = createAttributeSnapshot(attrs); + * // snapshot.id will be "snapshot(...).N" + * attrs.key1 = "changed"; // snapshot.get("key1") is still "value1" + * + * // Lazy copy-on-change for containers + * const container = createAttributeContainer(config, "my-container"); + * container.set("key1", "value1"); + * const snapshot2 = createAttributeSnapshot(container); + * // snapshot2.id will be "snapshot(my-container.N).M" + * container.set("key1", "changed"); // snapshot2.get("key1") remains "value1" (previous value copied) + * ``` + */ +export function createAttributeSnapshot(otelCfg: IOTelConfig, name: string, source: IOTelAttributes | IAttributeContainer, attribLimits?: IOTelAttributeLimits): IAttributeContainer { + let newContainer: IAttributeContainer; + + if (isAttributeContainer(source)) { + newContainer = _createSnapshotContainer(otelCfg, (name || "child") + "<-@[" + source.id + "]", source, attribLimits); + } else { + newContainer = createAttributeContainer(otelCfg, (name || "child") + "<-@[(...)]", null, attribLimits); + addAttributes(newContainer, source); + } + + return newContainer; +} + +/** + * Helper function to identify if a passed argument is or implements the IAttributeContainer interface + * @param value - The value to check + * @returns true if the value implements IAttributeContainer interface, false otherwise + * @since 3.4.0 + * @example + * ```typescript + * const container = createAttributeContainer(config, "test-container"); + * if (isAttributeContainer(container)) { + * // TypeScript now knows container is IAttributeContainer + * console.log(container.size); + * container.set("key", "value"); + * } + * + * // Check unknown object + * function processContainer(obj: unknown) { + * if (isAttributeContainer(obj)) { + * obj.forEach((key, value) => console.log(key, value)); + * } + * } + * ``` + */ +export function isAttributeContainer(value: any): value is IAttributeContainer { + return value && + isFunction(value.clear) && + isFunction(value.get) && + isFunction(value.has) && + isFunction(value.set) && + isFunction(value.del) && + isFunction(value.keys) && + isFunction(value.entries) && + isFunction(value.forEach) && + isFunction(value.values) && + isFunction(value.child) && + isFunction(value.listen) && + (isObject(value) || isFunction(value)) && + // Check for existence of the required properties, but don't cause them to be processed ("executed") + ("id" in value) && + ("size" in value) && + ("droppedAttributes" in value) && + ("attributes" in value); +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/enums/eAttributeChangeOp.ts b/shared/AppInsightsCore/src/OpenTelemetry/enums/eAttributeChangeOp.ts new file mode 100644 index 000000000..66bfc2a59 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/enums/eAttributeChangeOp.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createEnumStyle } from "../../JavaScriptSDK.Enums/EnumHelperFuncs"; + +/** + * Const enum for attribute change operation types + */ +export const enum eAttributeChangeOp { + /** + * Clear operation - clearing all attributes + */ + Clear = 0, + + /** + * Set operation - setting an attribute value (generic) + */ + Set = 1, + + /** + * Add operation - adding a new attribute that didn't exist before + */ + Add = 2, + + /** + * Delete operation - deleting an attribute + */ + Delete = 3, + + /** + * Dropped attributes - attributes that were dropped due to size limits + */ + DroppedAttributes = 4 +} + +/** + * Runtime enum style object for attribute change operation types + */ +export const AttributeChangeOp = createEnumStyle({ + Clear: eAttributeChangeOp.Clear, + Set: eAttributeChangeOp.Set, + Add: eAttributeChangeOp.Add, + Delete: eAttributeChangeOp.Delete +}); + +export type AttributeChangeOp = number | eAttributeChangeOp; \ No newline at end of file diff --git a/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSamplingDecision.ts b/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSamplingDecision.ts new file mode 100644 index 000000000..752adb2a1 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSamplingDecision.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createEnumStyle } from "../../../JavaScriptSDK.Enums/EnumHelperFuncs"; + +/** + * A sampling decision that determines how a {@link IOTelSpan} will be recorded + * and collected. + */ +export const enum eOTelSamplingDecision { + /** + * `Span.isRecording() === false`, span will not be recorded and all events + * and attributes will be dropped. + */ + NOT_RECORD = 0, + /** + * `Span.isRecording() === true`, but `Sampled` flag in {@link eW3CTraceFlags} + * MUST NOT be set. + */ + RECORD = 1, + /** + * `Span.isRecording() === true` AND `Sampled` flag in {@link eW3CTraceFlags} + * MUST be set. + */ + RECORD_AND_SAMPLED = 2, +} + +export const OTelSamplingDecision = (/* @__PURE__ */createEnumStyle({ + NOT_RECORD: eOTelSamplingDecision.NOT_RECORD, + RECORD: eOTelSamplingDecision.RECORD, + RECORD_AND_SAMPLED: eOTelSamplingDecision.RECORD_AND_SAMPLED +})); +export type OTelSamplingDecision = number | eOTelSamplingDecision; diff --git a/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSpanKind.ts b/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSpanKind.ts new file mode 100644 index 000000000..11b39bb2c --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSpanKind.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createEnumStyle } from "../../../JavaScriptSDK.Enums/EnumHelperFuncs"; + +/** + * The defined set of Span Kinds as defined by the OpenTelemetry. + */ +export const enum eOTelSpanKind { + /** Default value. Indicates that the span is used internally. */ + INTERNAL = 0, + + /** + * Indicates that the span covers server-side handling of an RPC or other + * remote request. + */ + SERVER = 1, + + /** + * Indicates that the span covers the client-side wrapper around an RPC or + * other remote request. + */ + CLIENT = 2, + + /** + * Indicates that the span describes producer sending a message to a + * broker. Unlike client and server, there is no direct critical path latency + * relationship between producer and consumer spans. + */ + PRODUCER = 3, + + /** + * Indicates that the span describes consumer receiving a message from a + * broker. Unlike client and server, there is no direct critical path latency + * relationship between producer and consumer spans. + */ + CONSUMER = 4, +} + +/** + * Creates an enum style object for the OTelSpanKind enum, providing the enum + * values as properties of the object as both string and number types. + * This allows for easy access to the enum values in a more readable format. + */ +export const OTelSpanKind = (/* @__PURE__ */createEnumStyle({ + INTERNAL: eOTelSpanKind.INTERNAL, + SERVER: eOTelSpanKind.SERVER, + CLIENT: eOTelSpanKind.CLIENT, + PRODUCER: eOTelSpanKind.PRODUCER, + CONSUMER: eOTelSpanKind.CONSUMER +})); + +export type OTelSpanKind = number | eOTelSpanKind; diff --git a/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSpanStatus.ts b/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSpanStatus.ts new file mode 100644 index 000000000..cf7116b60 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/enums/trace/OTelSpanStatus.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createEnumStyle } from "../../../JavaScriptSDK.Enums/EnumHelperFuncs"; + +/** + * An enumeration of status codes, matching the OpenTelemetry specification. + * + * @since 3.4.0 + */ +export const enum eOTelSpanStatusCode { + /** + * The default status. + */ + UNSET = 0, + /** + * The operation has been validated by an Application developer or + * Operator to have completed successfully. + */ + OK = 1, + /** + * The operation contains an error. + */ + ERROR = 2, +} + + +export const OTelSpanStatusCode = (/* @__PURE__ */createEnumStyle({ + UNSET: eOTelSpanStatusCode.UNSET, + OK: eOTelSpanStatusCode.OK, + ERROR: eOTelSpanStatusCode.ERROR +})); + +export type OTelSpanStatusCode = number | eOTelSpanStatusCode; diff --git a/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelError.ts b/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelError.ts new file mode 100644 index 000000000..08cdc2970 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelError.ts @@ -0,0 +1,28 @@ +import { CustomErrorConstructor, createCustomError } from "@nevware21/ts-utils"; + +let otelErrorType: OpenTelemetryErrorConstructor; + +export interface OpenTelemetryError extends Error { + +} + +export interface OpenTelemetryErrorConstructor extends CustomErrorConstructor { + + new (message?: string): T; + + (message?: string): T; +} + + +export function getOpenTelemetryError(): OpenTelemetryErrorConstructor { + if (!otelErrorType) { + otelErrorType = createCustomError("OpenTelemetryError", function (self, args) { + }); + } + + return otelErrorType; +} + +export function throwOTelError(message: string): never { + throw new (getOpenTelemetryError())(message); +} \ No newline at end of file diff --git a/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelInvalidAttributeError.ts b/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelInvalidAttributeError.ts new file mode 100644 index 000000000..f77ceed5c --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelInvalidAttributeError.ts @@ -0,0 +1,31 @@ +import { createCustomError } from "@nevware21/ts-utils"; +import { STR_EMPTY } from "../../JavaScriptSDK/InternalConstants"; +import { OpenTelemetryError, OpenTelemetryErrorConstructor, getOpenTelemetryError } from "./OTelError"; + +let otelInvalidAttributeErrorType: OTelInvalidAttributeErrorConstructor; + +export interface OTelInvalidAttributeError extends OpenTelemetryError { + readonly attribName: string; + + readonly value: any; +} + +interface OTelInvalidAttributeErrorConstructor extends OpenTelemetryErrorConstructor { + + new (message?: string, attribName?: string, value?: any): OTelInvalidAttributeError; + + (message?: string, attribName?: string, value?: any): OTelInvalidAttributeError; +} + +export function throwOTelInvalidAttributeError(message: string, attribName: string, value: any): void { + if (!otelInvalidAttributeErrorType) { + otelInvalidAttributeErrorType = createCustomError("OTelInvalidAttributeError", function (self, args) { + let len = args.length; + + self.attribName = len > 1 ? args[1] : STR_EMPTY; + self.value = len > 2 ? args[2] : STR_EMPTY; + }, getOpenTelemetryError()); + } + + throw new otelInvalidAttributeErrorType(message, attribName, value); +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelSpanError.ts b/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelSpanError.ts new file mode 100644 index 000000000..fbadededa --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/errors/OTelSpanError.ts @@ -0,0 +1,28 @@ +import { createCustomError } from "@nevware21/ts-utils"; +import { STR_EMPTY } from "../../JavaScriptSDK/InternalConstants"; +import { OpenTelemetryError, OpenTelemetryErrorConstructor, getOpenTelemetryError } from "./OTelError"; + +let otelSpanErrorType: OTelSpanErrorConstructor; + +export interface OTelSpanError extends OpenTelemetryError { + readonly spanName: string; +} + +interface OTelSpanErrorConstructor extends OpenTelemetryErrorConstructor { + + new (message?: string, spanName?: string): OTelSpanError; + + (message?: string, spanName?: string): OTelSpanError; +} + +export function throwOTelSpanError(message: string, spanName: string): never { + if (!otelSpanErrorType) { + otelSpanErrorType = createCustomError("OTelSpanError", (self, args) => { + let len = args.length; + + self.spanName = len > 1 ? args[1] : STR_EMPTY; + }, getOpenTelemetryError()); + } + + throw new otelSpanErrorType(message, spanName); +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/helpers/attributeHelpers.ts b/shared/AppInsightsCore/src/OpenTelemetry/helpers/attributeHelpers.ts new file mode 100644 index 000000000..9949b1020 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/helpers/attributeHelpers.ts @@ -0,0 +1,85 @@ +import { arrForEach, arrSlice, isArray, isObject, isString, objForEachKey } from "@nevware21/ts-utils"; +import { createAttributeContainer } from "../attribute/attributeContainer"; +import { IOTelApi } from "../interfaces/IOTelApi"; +import { IOTelAttributes, OTelAttributeValue } from "../interfaces/IOTelAttributes"; +import { handleWarn } from "./handleErrors"; + +function _isSupportedType(theType: string): boolean { + return theType === "number" || theType === "boolean" || theType === "string"; +} + +function _isHomogeneousArray(arr: unknown[]): boolean { + let type: string | undefined; + let result = true; + + arrForEach(arr, (element) => { + // null/undefined elements are allowed + if (element !== null) { + let elType = typeof element; + + if (!type) { + result = _isSupportedType(elType); + type = elType; + } else { + result = (type === elType); + } + } + + if (!result) { + return -1; + } + }); + + return result; +} + + /** + * Helper to determine if the provided key is a valid attribute key + * @param key - The key to check + * @returns true if the key is a valid attribute key + */ +export function isAttributeKey(key: unknown): key is string { + return isString(key) && !!key; +} + +/** + * Helper to determine if the provided value is a valid attribute value + * @param val - The value to check + * @returns true if the value is a valid attribute value + */ +export function isAttributeValue(val: unknown): val is OTelAttributeValue { + let result = (val === null || _isSupportedType(typeof val)); + if (val && isArray(val)) { + result = _isHomogeneousArray(val); + } + + return result; +} + +/** + * Sanitize the provided attributes to ensure they conform to OTel attribute requirements + * @param otelApi - The OpenTelemetry API instance + * @param attributes - The attributes to sanitize + * @returns The sanitized attributes + */ +export function sanitizeAttributes(otelApi: IOTelApi, attributes: unknown): IOTelAttributes { + let container = createAttributeContainer(otelApi.cfg); + + if (!isObject(attributes) || attributes == null) { + return {}; + } + + objForEachKey(attributes, (key: string, val: unknown) => { + if (!isAttributeKey(key)) { + handleWarn(otelApi.cfg.errorHandlers, "Invalid attribute key: " + key); + } else if (!isAttributeValue(val)) { + handleWarn(otelApi.cfg.errorHandlers, "Invalid attribute value set for : " + key); + } else if (isArray(val)) { + container.set(key, arrSlice(val as any) as OTelAttributeValue); + } else { + container.set(key, val); + } + }); + + return container.attributes; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/helpers/common.ts b/shared/AppInsightsCore/src/OpenTelemetry/helpers/common.ts new file mode 100644 index 000000000..2ec2f8a4a --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/helpers/common.ts @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ILazyValue, asString, dumpObj, isError, isObject, isPrimitive, safe, safeGetLazy } from "@nevware21/ts-utils"; +import { getJSON } from "../../JavaScriptSDK/EnvUtils"; +import { STR_EMPTY } from "../../JavaScriptSDK/InternalConstants"; +import { IAttributeContainer } from "../attribute/IAttributeContainer"; +import { + DBSYSTEMVALUES_DB2, DBSYSTEMVALUES_DERBY, DBSYSTEMVALUES_H2, DBSYSTEMVALUES_HSQLDB, DBSYSTEMVALUES_MARIADB, DBSYSTEMVALUES_MSSQL, + DBSYSTEMVALUES_ORACLE, DBSYSTEMVALUES_OTHER_SQL, DBSYSTEMVALUES_SQLITE +} from "../attribute/SemanticConventions"; +import { OTelAttributeValue } from "../interfaces/IOTelAttributes"; + +const _hasJsonStringify: ILazyValue = (/*#__PURE__*/ safeGetLazy(() => !!getJSON().stringify, null)); + +const SYNTHETIC_TYPE = (/*#__PURE__*/ "user_agent.synthetic.type"); +const CLIENT_DOT = (/*#__PURE__*/ "client."); +const HTTP_DOT = (/*#__PURE__*/ "http."); +const NET_DOT = (/*#__PURE__*/ "net."); +const PEER_DOT = (/*#__PURE__*/ "peer."); + +const ATTR_NETWORK_PEER_ADDRESS = (/*#__PURE__*/ "network.peer.address"); +const SEMATTRS_NET_PEER_IP = (/*#__PURE__*/ NET_DOT + PEER_DOT + "ip"); +const ATTR_CLIENT_ADDRESS = (/*#__PURE__*/ CLIENT_DOT + "address"); +const SEMATTRS_HTTP_CLIENT_IP = (/*#__PURE__*/ HTTP_DOT + "client_ip"); +const ATTR_USER_AGENT_ORIGINAL = (/*#__PURE__*/ "user_agent.original"); +const SEMATTRS_HTTP_USER_AGENT = (/*#__PURE__*/ HTTP_DOT + "user_agent"); +const ATTR_URL_FULL = (/*#__PURE__*/ "url.full"); +const SEMATTRS_HTTP_URL = (/*#__PURE__*/ HTTP_DOT + "url"); +const ATTR_HTTP_REQUEST_METHOD = (/*#__PURE__*/ HTTP_DOT + "request.method"); +const SEMATTRS_HTTP_METHOD = (/*#__PURE__*/ HTTP_DOT + "method"); +const ATTR_HTTP_RESPONSE_STATUS_CODE = (/*#__PURE__*/ HTTP_DOT + "response.status_code"); +const SEMATTRS_HTTP_STATUS_CODE = (/*#__PURE__*/ HTTP_DOT + "status_code"); +const ATTR_URL_SCHEME = (/*#__PURE__*/ "url.scheme"); +const SEMATTRS_HTTP_SCHEME = (/*#__PURE__*/ HTTP_DOT + "scheme"); +const ATTR_URL_PATH = (/*#__PURE__*/ "url.path"); +const ATTR_URL_QUERY = (/*#__PURE__*/ "url.query"); +const SEMATTRS_HTTP_TARGET = (/*#__PURE__*/ HTTP_DOT + "target"); +const ATTR_SERVER_ADDRESS = (/*#__PURE__*/ "server.address"); +const SEMATTRS_HTTP_HOST = (/*#__PURE__*/ HTTP_DOT + "host"); +const SEMATTRS_NET_PEER_NAME = (/*#__PURE__*/ NET_DOT + PEER_DOT + "name"); +const ATTR_CLIENT_PORT = (/*#__PURE__*/ CLIENT_DOT + "port"); +const ATTR_SERVER_PORT = (/*#__PURE__*/ "server.port"); +const SEMATTRS_NET_PEER_PORT = (/*#__PURE__*/ NET_DOT + PEER_DOT + "port"); +const SEMATTRS_PEER_SERVICE = (/*#__PURE__*/ PEER_DOT + "service"); + +/** + * Get the URL from the attribute container + * @param container - The attribute container to extract the URL from + * @returns The constructed URL string + */ +/* #__NO_SIDE_EFFECTS__ */ +export function getUrl(container: IAttributeContainer): string { + let result = ""; + if (container) { + const httpMethod = getHttpMethod(container); + if (httpMethod) { + const httpUrl = getHttpUrl(container); + if (httpUrl) { + result = asString(httpUrl); + } else { + const httpScheme = getHttpScheme(container); + const httpTarget = getHttpTarget(container); + if (httpScheme && httpTarget) { + const httpHost = getHttpHost(container); + if (httpHost) { + result = httpScheme + "://" + httpHost + httpTarget; + } else { + const netPeerPort = getNetPeerPort(container); + if (netPeerPort) { + const netPeerName = getNetPeerName(container); + if (netPeerName) { + result = httpScheme + "://" + netPeerName + ":" + netPeerPort + httpTarget; + } else { + const netPeerIp = getPeerIp(container); + if (netPeerIp) { + result = httpScheme + "://" + netPeerIp + ":" + netPeerPort + httpTarget; + } + } + } + } + } + } + } + } + + return result; +} + +/** + * Determine if the attribute container represents a synthetic source + * @param container - The attribute container to check + * @returns True if the attribute container is from a synthetic source + */ +/* #__NO_SIDE_EFFECTS__ */ +export function isSyntheticSource(container: IAttributeContainer): boolean { + return !!container.get(SYNTHETIC_TYPE); +} + +/** + * Serialize an attribute value to a string value + * @param value - The attribute value to serialize + * @returns The serialized string value + */ +/* #__NO_SIDE_EFFECTS__ */ +export function serializeAttribute(value: any): string | number | bigint | boolean | undefined | symbol | null { + let result: string | number | boolean | null | undefined; + if (isError(value)) { + result = dumpObj(value); + } else if ((!value.toString || isObject(value)) && _hasJsonStringify.v) { + result = safe(getJSON().stringify, [value]).v; + } else if (isPrimitive(value)) { + // We keep primitives as-is, so that the standard attribute types are preserved + // These are converted as required in the sending channel(s) + result = value as any; + } else { + result = asString(value); + } + + // Return scalar and undefined values + return result; +} + +/** + * Peer address of the network connection - IP address or Unix domain socket name. + * + * @example 10.1.2.80 + * @example /tmp/my.sock + * @since 3.4.0 + */ +/* #__NO_SIDE_EFFECTS__ */ +export function getPeerIp(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_NETWORK_PEER_ADDRESS) || container.get(SEMATTRS_NET_PEER_IP); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getLocationIp(container: IAttributeContainer): OTelAttributeValue | undefined { + let result: OTelAttributeValue | undefined; + if (container) { + const httpClientIp = getHttpClientIp(container); + if (httpClientIp) { + result = asString(httpClientIp); + } + + if (!result) { + const netPeerIp = getPeerIp(container); + if (netPeerIp) { + result = asString(netPeerIp); + } + } + } + + return result; +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getHttpClientIp(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_CLIENT_ADDRESS) || container.get(SEMATTRS_HTTP_CLIENT_IP); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getUserAgent(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_USER_AGENT_ORIGINAL) || container.get(SEMATTRS_HTTP_USER_AGENT); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getHttpUrl(container: IAttributeContainer): OTelAttributeValue | undefined { + // Stable sem conv only supports populating url from `url.full` + if (container) { + return container.get(ATTR_URL_FULL) || container.get(SEMATTRS_HTTP_URL); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getHttpMethod(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_HTTP_REQUEST_METHOD) || container.get(SEMATTRS_HTTP_METHOD); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getHttpStatusCode(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_HTTP_RESPONSE_STATUS_CODE) || container.get(SEMATTRS_HTTP_STATUS_CODE); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getHttpScheme(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_URL_SCHEME) || container.get(SEMATTRS_HTTP_SCHEME); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getHttpTarget(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_URL_PATH) || container.get(ATTR_URL_QUERY) || container.get(SEMATTRS_HTTP_TARGET); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getHttpHost(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_SERVER_ADDRESS) || container.get(SEMATTRS_HTTP_HOST); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getNetPeerName(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return container.get(ATTR_CLIENT_ADDRESS) || container.get(SEMATTRS_NET_PEER_NAME); + } +} + +/* #__NO_SIDE_EFFECTS__ */ +export function getNetPeerPort(container: IAttributeContainer): OTelAttributeValue | undefined { + if (container) { + return ( + container.get(ATTR_CLIENT_PORT) || + container.get(ATTR_SERVER_PORT) || + container.get(SEMATTRS_NET_PEER_PORT) + ); + } +} + +export function getDependencyTarget(container: IAttributeContainer): string { + let result: string; + if (container) { + const peerService = container.get(SEMATTRS_PEER_SERVICE); + if (peerService) { + result = asString(peerService); + } + + if (!result) { + const httpHost = getHttpHost(container); + if (httpHost) { + result = asString(httpHost); + } + } + + if (!result) { + const httpUrl = getHttpUrl(container); + if (httpUrl) { + result = asString(httpUrl); + } + } + + if (!result) { + const netPeerName = getNetPeerName(container); + if (netPeerName) { + result = asString(netPeerName); + } + } + if (!result) { + const netPeerIp = getPeerIp(container); + if (netPeerIp) { + result = asString(netPeerIp); + } + } + } + + return result || STR_EMPTY; +} + +/** #__NO_SIDE_EFFECTS__ */ +export function isSqlDB(dbSystem: string): boolean { + return ( + dbSystem === DBSYSTEMVALUES_DB2 || + dbSystem === DBSYSTEMVALUES_DERBY || + dbSystem === DBSYSTEMVALUES_MARIADB || + dbSystem === DBSYSTEMVALUES_MSSQL || + dbSystem === DBSYSTEMVALUES_ORACLE || + dbSystem === DBSYSTEMVALUES_SQLITE || + dbSystem === DBSYSTEMVALUES_OTHER_SQL || + dbSystem === DBSYSTEMVALUES_HSQLDB || + dbSystem === DBSYSTEMVALUES_H2 + ); +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/helpers/handleErrors.ts b/shared/AppInsightsCore/src/OpenTelemetry/helpers/handleErrors.ts new file mode 100644 index 000000000..73db07a4e --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/helpers/handleErrors.ts @@ -0,0 +1,104 @@ +import { dumpObj, fnApply } from "@nevware21/ts-utils"; +import { IOTelErrorHandlers } from "../interfaces/config/IOTelErrorHandlers"; + +/** + * Handle / report an error. + * When not provided the default is to generally throw an {@link OTelInvalidAttributeError} + * @param handlers - The error handlers configuration + * @param message - The error message to report + * @param key - The attribute key that caused the error + * @param value - The attribute value that caused the error + */ +/*#__NO_SIDE_EFFECTS__*/ +export function handleAttribError(handlers: IOTelErrorHandlers, message: string, key: string, value: any) { + if (handlers.attribError) { + handlers.attribError(message, key, value); + } else { + handleWarn(handlers, message + " for [" + key + "]: " + dumpObj(value)); + } +} + +/** + * There was an error with the span. + * @param handlers - The error handlers configuration + * @param message - The message to report + * @param spanName - The name of the span + */ +/*#__NO_SIDE_EFFECTS__*/ +export function handleSpanError(handlers: IOTelErrorHandlers, message: string, spanName: string) { + if (handlers.spanError) { + handlers.spanError(message, spanName); + } else { + handleWarn(handlers, "Span [" + spanName + "]: " + message); + } +} + +/** + * Report a general debug message, should not be treated as fatal + * @param handlers - The error handlers configuration + * @param message - The debug message to report + */ +/*#__NO_SIDE_EFFECTS__*/ +export function handleDebug(handlers: IOTelErrorHandlers, message: string) { + if (handlers.debug) { + handlers.debug(message); + } else { + if (console) { + let fn = console.log; + fnApply(fn, console, [message]); + } + } +} + +/** + * Report a general warning, should not be treated as fatal + * @param handlers - The error handlers configuration + * @param message - The warning message to report + */ +/*#__NO_SIDE_EFFECTS__*/ +export function handleWarn(handlers: IOTelErrorHandlers, message: string) { + if (handlers.warn) { + handlers.warn(message); + } else { + if (console) { + let fn = console.warn || console.log; + fnApply(fn, console, [message]); + } + } +} + +/** + * Report a general error, should not be treated as fatal + * @param handlers - The error handlers configuration + * @param message - The error message to report + */ +/*#__NO_SIDE_EFFECTS__*/ +export function handleError(handlers: IOTelErrorHandlers, message: string) { + if (handlers.error) { + handlers.error(message); + } else if (handlers.warn) { + handlers.warn(message); + } else { + if (console) { + let fn = console.error || console.warn || console.log; + fnApply(fn, console, [message]); + } + } +} + +/** + * A general error handler for not implemented methods. + * @param handlers - The error handlers configuration + * @param message - The message to report + */ +/*#__NO_SIDE_EFFECTS__*/ +export function handleNotImplemented(handlers: IOTelErrorHandlers, message: string) { + if (handlers.notImplemented) { + handlers.notImplemented(message); + } else { + if (console) { + let fn = console.error || console.log; + fnApply(fn, console, [message]); + } + } +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/helpers/timeHelpers.ts b/shared/AppInsightsCore/src/OpenTelemetry/helpers/timeHelpers.ts new file mode 100644 index 000000000..35566663c --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/helpers/timeHelpers.ts @@ -0,0 +1,404 @@ +import { + ICachedValue, ObjDefinePropDescriptor, createCachedValue, getDeferred, getPerformance, isArray, isDate, isNullOrUndefined, isNumber, + isString, mathFloor, mathRound, objDefine, objDefineProps, objFreeze, perfNow, strLeft, strRight, strSplit, throwTypeError +} from "@nevware21/ts-utils"; +import { setObjStringTag, toISOString } from "../../JavaScriptSDK/HelperFuncs"; +import { INVALID_TRACE_ID } from "../../JavaScriptSDK/W3cTraceParent"; +import { IOTelHrTime, OTelTimeInput } from "../interfaces/IOTelHrTime"; + +const NANOSECOND_DIGITS = 9; + +// Constants for time unit conversions and manipulation +const NANOS_IN_MILLIS = /*#__PURE__*/ 1000000; // Number of nanoseconds in a millisecond +const NANOS_IN_SECOND = /*#__PURE__*/ 1000000000; // Number of nanoseconds in a second +const MICROS_IN_SECOND = /*#__PURE__*/ 1000000; // Number of microseconds in a second +const MICROS_IN_MILLIS = /*#__PURE__*/ 1000; // Number of microseconds in a millisecond +const MILLIS_IN_SECOND = /*#__PURE__*/ 1000; // Number of milliseconds in a second + +interface IOriginHrTime { + to: number; + hr: IOTelHrTime +} + +let cSecondsToNanos: ICachedValue; +let cTimeOrigin: ICachedValue; +let cNanoPadding: ICachedValue; + +function _notMutable() { + throwTypeError("HrTime is not mutable") +} + +/** + * Initialize the cached value for converting milliseconds to nanoseconds. + * @returns + */ +/*#__PURE__*/ +function _initSecondsToNanos(): ICachedValue { + if (!cSecondsToNanos) { + cSecondsToNanos = createCachedValue(NANOS_IN_SECOND); + } + return cSecondsToNanos; +} + +/** + * Initialize the time origin. + * @returns + */ +/*#__PURE__*/ +function _initTimeOrigin(): ICachedValue { + if (!cTimeOrigin) { + let timeOrigin = 0; + let perf = getPerformance(); + if (perf) { + timeOrigin = perf.timeOrigin; + if (!isNumber(timeOrigin)) { + timeOrigin = (perf as any).timing && (perf as any).timing.fetchStart; + } + + if (!isNumber(timeOrigin) && perf.now) { + timeOrigin = perf.now(); + } + } + + cTimeOrigin = createCachedValue({ + to: timeOrigin, + hr: millisToHrTime(timeOrigin) + }); + } + return cTimeOrigin; +} + +/*#__PURE__*/ +function _finalizeHrTime(hrTime: IOTelHrTime) { + function _toString() { + return "[" + hrTime[0] + ", " + hrTime[1] + "]"; + } + + setObjStringTag(hrTime, _toString); + + return objFreeze(hrTime); +} + +/*#__PURE__*/ +function _createUnixNanoHrTime(unixNano: number): IOTelHrTime { + // Create array with initial length of 2 + const hrTime = [0, 0] as any as IOTelHrTime; + const immutable: ObjDefinePropDescriptor = { v: _notMutable, w: false, e: false }; + + // Define the array elements and other properties (avoid redefining length) + objDefineProps(hrTime, { + 0: { + l: getDeferred(() => mathFloor(unixNano / NANOS_IN_SECOND)) + }, + 1: { + l: getDeferred(() => unixNano % NANOS_IN_SECOND) + }, + // unixNano: { + // v: unixNano, + // e: false, + // w: false + // }, + // Override array mutating methods with single _notMutable function + push: immutable, + pop: immutable, + shift: immutable, + unshift: immutable, + splice: immutable, + sort: immutable, + reverse: immutable, + fill: immutable, + copyWithin: immutable + }); + + return _finalizeHrTime(hrTime); +} + +/*#__PURE__*/ +function _createHrTime(seconds: number, nanoseconds: number): IOTelHrTime { + const hrTime = [seconds, nanoseconds] as IOTelHrTime; + + // objDefine(hrTime, "unixNano", { + // v: (seconds * NANOS_IN_SECOND) + nanoseconds, + // w: false, + // e: false + // }); + + return _finalizeHrTime(hrTime); +} + +/** + * Returns a new HrTime object with zero values for seconds and nanoseconds. + * @returns A HrTime object representing zero time. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function zeroHrTime(): IOTelHrTime { + return _createUnixNanoHrTime(0); +} + +/** + * Converts a number of milliseconds from epoch to HrTime([seconds, remainder in nanoseconds]). + * @param epochMillis - The number of milliseconds since the epoch (January 1, 1970). + * @returns A HrTime object representing the converted time. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function millisToHrTime(epochMillis: number): IOTelHrTime { + let result: IOTelHrTime; + + if (epochMillis > 0) { + // Handle whole and fractional parts separately for maximum precision + const wholeMillis = mathFloor(epochMillis); + const fractionalMillis = epochMillis - wholeMillis; + + // Handle whole milliseconds using integer arithmetic + const seconds = mathFloor(wholeMillis / MILLIS_IN_SECOND); + const millisFromSeconds = wholeMillis % MILLIS_IN_SECOND; + const nanosFromWholeMillis = millisFromSeconds * NANOS_IN_MILLIS; + + // Convert fractional milliseconds to nanoseconds with proper rounding + // Use Math.round to properly handle cases where we need to round up + const nanosFromFraction = mathRound(fractionalMillis * NANOS_IN_MILLIS); + + // Combine the nanoseconds parts and handle any potential overflow + let totalNanos = nanosFromWholeMillis + nanosFromFraction; + let adjustedSeconds = seconds; + + // Check if we need to increment the seconds + if (totalNanos >= NANOS_IN_SECOND) { + adjustedSeconds++; + totalNanos -= NANOS_IN_SECOND; + } + + result = _createHrTime(adjustedSeconds, totalNanos); + } + + return result || zeroHrTime(); +} + +/** + * Converts a number of nanoseconds to HrTime([seconds, remainder in nanoseconds]). + * @param nanos - The number of nanoseconds since the epoch (January 1, 1970). + * @returns A HrTime object representing the converted time. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function nanosToHrTime(nanos: number): IOTelHrTime { + let result: IOTelHrTime; + if (nanos > 0) { + result = _createUnixNanoHrTime(nanos); + } + return result || zeroHrTime(); +} + +// /** +// * Converts a HrTime object to a number representing nanoseconds since epoch. +// * Note: Due to JavaScript number limitations, values greater than Number.MAX_SAFE_INTEGER +// * may lose precision. For very large time values, consider using string representation +// * or splitting into separate second/nanosecond components. +// * @param hrTime - The HrTime object to convert. +// * @returns The number of nanoseconds represented by the HrTime object. +// */ +// /*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +// export function hrTimeToUnixNanos(hrTime: IOTelHrTime): number { +// let value = hrTime.unixNano; +// if (isNullOrUndefined(value)) { +// // Handle legacy HRTime format using standard number operations +// // First calculate seconds contribution to nanoseconds +// const secondsInNanos = hrTime[0] * NANOS_IN_MILLIS; +// // Add the additional nanoseconds +// value = secondsInNanos + hrTime[1]; + +// // // Add warning if we're approaching number precision limits +// // if (Math.abs(value) > Number.MAX_SAFE_INTEGER) { +// // console.warn("Time value exceeds safe integer limits, precision may be lost"); +// // } +// } + +// return value; +// } + +/** + * Returns an hrtime calculated via performance component. + * @param performanceNow - The current time in milliseconds since the epoch. + */ +/*#__PURE__*/ +export function hrTime(performanceNow?: number): IOTelHrTime { + let result = millisToHrTime(isNumber(performanceNow) ? performanceNow : perfNow()); + const perf = getPerformance(); + if (perf) { + const timeOrigin = cTimeOrigin || _initTimeOrigin(); + result = addHrTimes(timeOrigin.v.hr, result); + } + + return result; +} + +/** + * Converts a TimeInput to an HrTime, defaults to _hrtime(). + * @param time - The time input to convert. + */ +/*#__PURE__*/ +export function timeInputToHrTime(time: OTelTimeInput): IOTelHrTime { + let result: IOTelHrTime; + + if (!isTimeInputHrTime(time)) { + if (isNumber(time)) { + const timeOrigin = cTimeOrigin || _initTimeOrigin(); + // Must be a performance.now() if it's smaller than process start time + result = (time < timeOrigin.v.to) ? hrTime(time) : millisToHrTime(time); + } else if (isDate(time)) { + result = millisToHrTime((time as Date).getTime()); + } else { + throwTypeError("Invalid input type"); + } + } else { + // Convert HrTime array to IOTelHrTime + result = _createHrTime(time[0], time[1]); + } + + return result; +} + +/** + * Returns a duration of two hrTime. + * @param startTime - The start time of the duration + * @param endTime - The end time of the duration + * @returns The duration between startTime and endTime as an IOTelHrTime + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function hrTimeDuration(startTime: IOTelHrTime, endTime: IOTelHrTime): IOTelHrTime { + const seconds = endTime[0] - startTime[0]; + let nanos = endTime[1] - startTime[1]; + + // overflow + if (nanos < 0) { + const adjustedSeconds = seconds - 1; + // negate + const nanoSeconds = cSecondsToNanos || _initSecondsToNanos(); + nanos += nanoSeconds.v; + return _createHrTime(adjustedSeconds, nanos); + } + + return _createHrTime(seconds, nanos); +} + +/** + * Convert hrTime to timestamp, for example "2019-05-14T17:00:00.000123456Z" + * @param time - The hrTime to convert. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function hrTimeToTimeStamp(time: IOTelHrTime): string { + if (!cNanoPadding) { + cNanoPadding = createCachedValue(strLeft(INVALID_TRACE_ID, NANOSECOND_DIGITS)); + } + + const date = toISOString(new Date(time[0] * 1000)); + return date.replace("000Z", strRight(cNanoPadding.v + time[1] + "Z", NANOSECOND_DIGITS + 1)); +} + +/** + * Convert hrTime to nanoseconds. + * @param time - The hrTime to convert. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function hrTimeToNanoseconds(time: IOTelHrTime): number { + let nanoSeconds = cSecondsToNanos || _initSecondsToNanos(); + return time[0] * nanoSeconds.v + time[1]; +} + +/** + * Convert hrTime to milliseconds. + * @param time - The hrTime to convert. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function hrTimeToMilliseconds(time: IOTelHrTime): number { + // Use integer math for the seconds part to avoid floating point precision loss + const millisFromSeconds = time[0] * MILLIS_IN_SECOND; + // Convert nanoseconds to milliseconds with proper rounding + const millisFromNanos = Math.round(time[1] / NANOS_IN_MILLIS); + return millisFromSeconds + millisFromNanos; +} + +/** + * Convert hrTime to microseconds. + * @param time - The hrTime to convert. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function hrTimeToMicroseconds(time: IOTelHrTime): number { + // Use integer math for the seconds part to avoid floating point precision loss + const microsFromSeconds = time[0] * MICROS_IN_SECOND; + // Convert nanoseconds to microseconds with proper rounding + const microsFromNanos = Math.round(time[1] / MICROS_IN_MILLIS); + return microsFromSeconds + microsFromNanos; +} + +/** + * check if time is HrTime + * @param value - The value to check. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function isTimeInputHrTime(value: unknown): value is IOTelHrTime { + return isArray(value) && value.length === 2 && isNumber(value[0]) && isNumber(value[1]); +} + +/** + * check if input value is a correct types.TimeInput + * @param value - The value to check. + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function isTimeInput(value: unknown): value is OTelTimeInput { + return !isNullOrUndefined(value) && (isTimeInputHrTime(value) || isNumber(value) || isDate(value)); +} + +/** + * A helper method to determine whether the provided value is in a ISO time span format (DD.HH:MM:SS.MMMMMM) + * @param value - The value to check + * @returns True if the value is in a time span format; false otherwise + */ +export function isTimeSpan(value: any): value is string { + let result = false; + + if (isString(value)) { + const parts = strSplit(value, ":"); + if (parts.length === 3) { + // Looks like a candidate, now validate each part + const daysHours = strSplit(parts[0], "."); + if (daysHours.length === 2) { + result = !isNaN(parseInt(daysHours[0] || "0")) && !isNaN(parseInt(daysHours[1] || "0")); + } else { + result = !isNaN(parseInt(daysHours[0] || "0")); + } + + result = result && !isNaN(parseInt(parts[1] || "0")); + + const secondsParts = strSplit(parts[2], "."); + if (secondsParts.length === 2) { + result = result && !isNaN(parseInt(secondsParts[0] || "0")) && !isNaN(parseInt(secondsParts[1] || "0")); + } else { + result = result && !isNaN(parseInt(secondsParts[0] || "0")); + } + } + } + + return result; +} + + +/** + * Given 2 HrTime formatted times, return their sum as an HrTime. + * @param time1 - The first HrTime to add + * @param time2 - The second HrTime to add + * @returns The sum of the two HrTime values as an IOTelHrTime + */ +/*#__PURE__*/ /*@__NO_SIDE_EFFECTS__*/ +export function addHrTimes(time1: IOTelHrTime, time2: IOTelHrTime): IOTelHrTime { + const seconds = time1[0] + time2[0]; + let nanos = time1[1] + time2[1]; + const nanoSeconds = cSecondsToNanos || _initSecondsToNanos(); + + // Nanoseconds overflow check + if (nanos >= nanoSeconds.v) { + nanos -= nanoSeconds.v; + return _createHrTime(seconds + 1, nanos); + } + + return _createHrTime(seconds, nanos); +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelApi.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelApi.ts new file mode 100644 index 000000000..26c0cd1f1 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelApi.ts @@ -0,0 +1,55 @@ +import { ITraceHost } from "../../applicationinsights-core-js"; +import { IOTelConfig } from "./config/IOTelConfig"; +import { IOTelTracerProvider } from "./trace/IOTelTracerProvider"; +import { ITraceApi } from "./trace/ITraceApi"; + +/** + * The main OpenTelemetry API interface that provides access to all OpenTelemetry functionality. + * This interface extends the IOTelTracerProvider and serves as the entry point for OpenTelemetry operations. + * + * @example + * ```typescript + * // Get a tracer from the API instance + * const tracer = otelApi.getTracer("my-component"); + * + * // Create a span + * const span = tracer.startSpan("operation"); + * + * // Access context manager + * const currentContext = otelApi.context.active(); + * + * // Access trace API + * const activeSpan = otelApi.trace.getActiveSpan(); + * ``` + * + * @since 3.4.0 + */ +export interface IOTelApi extends IOTelTracerProvider { + /** + * The configuration object that contains all OpenTelemetry-specific settings. + * This includes tracing configuration, error handlers, and other OpenTelemetry options. + * + * @remarks + * Changes to this configuration after initialization may not take effect until + * the next telemetry operation, depending on the implementation. + */ + cfg: IOTelConfig; + + /** + * The current {@link ITraceHost} instance for this IOTelApi instance, this is effectively + * the OpenTelemetry ContextAPI instance without the static methods. + * @returns The ContextManager instance + */ + host: ITraceHost; + + /** + * The current {@link ITraceApi} instance for this IOTelApi instance, this is + * effectively the OpenTelemetry TraceAPI instance without the static methods. + * @returns The current {@link ITraceApi} instance + */ + trace: ITraceApi; + + // propagation?: IOTelPropagationApi; + + // metrics?: IOTelMetricsApi; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelApiCtx.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelApiCtx.ts new file mode 100644 index 000000000..fae0cbc6a --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelApiCtx.ts @@ -0,0 +1,15 @@ +import { ITraceHost } from "../../JavaScriptSDK.Interfaces/ITraceProvider"; + +/** + * The context for the current IOTelApi instance linking it to the core SDK instance, + * including access to the core dynamic configuration. + * + * Note: Passing the core instance within a context object to allow future expansion + * without breaking changes or modifying signatures. Also allows easier mocking for tests. + */ +export interface IOTelApiCtx { + /** + * The host instance associated with this OTel API instance + */ + host: ITraceHost; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelAttributes.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelAttributes.ts new file mode 100644 index 000000000..ad2073535 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelAttributes.ts @@ -0,0 +1,34 @@ + +/** + * Attribute values may be any non-nullish primitive value except an object. + * + * null or undefined attribute values are invalid and will result in undefined behavior. + * + * @since 3.4.0 + */ +export type OTelAttributeValue = + | string + | number + | boolean + | Array + | Array + | Array; + +/** + * Defines extended attribute values which may contain nested attributes. + * + * @since 3.4.0 + */ +export type ExtendedOTelAttributeValue = OTelAttributeValue | IOTelAttributes; + +/** + * Attributes is a map from string to attribute values. + * + * Note: only the own enumerable keys are counted as valid attribute keys. + * + * @since 3.4.0 + */ +export interface IOTelAttributes { + [key: string]: OTelAttributeValue | undefined; +} + diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelException.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelException.ts new file mode 100644 index 000000000..6e41000e7 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelException.ts @@ -0,0 +1,35 @@ + +export interface IOTelExceptionWithCode { + code: string | number; + name?: string; + message?: string; + stack?: string; +} + +export interface IOTelExceptionWithMessage { + code?: string | number; + message: string; + name?: string; + stack?: string; +} + +export interface IOTelExceptionWithName { + code?: string | number; + message?: string; + name: string; + stack?: string; +} + +/** + * Defines Exception. + * + * string or an object with one of (message or name or code) and optional stack + * + * @since 3.4.0 + */ +export type OTelException = + | IOTelExceptionWithCode + | IOTelExceptionWithMessage + | IOTelExceptionWithName + | string; + \ No newline at end of file diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelHrTime.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelHrTime.ts new file mode 100644 index 000000000..2316786cd --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/IOTelHrTime.ts @@ -0,0 +1,79 @@ + +/** + * High-resolution time represented as a tuple of [seconds, nanoseconds]. + * This is the base type for all OpenTelemetry high-resolution time values. + * + * @remarks + * The first element represents seconds since Unix epoch, and the second element + * represents nanoseconds (0-999,999,999) within that second. + * + * @example + * ```typescript + * const hrTime: OTelHrTimeBase = [1609459200, 500000000]; // 2021-01-01 00:00:00.5 UTC + * ``` + * + * @since 3.4.0 + */ +export type OTelHrTimeBase = [number, number]; + +/** + * Enhanced high-resolution time interface that extends the base tuple with additional properties. + * Provides a more structured way to work with high-resolution timestamps. + * + * @example + * ```typescript + * const hrTime: IOTelHrTime = { + * 0: 1609459200, // seconds since Unix epoch + * 1: 500000000, // nanoseconds (0-999,999,999) + * }; + * ``` + * + * @since 3.4.0 + */ +export interface IOTelHrTime extends OTelHrTimeBase { + /** + * Seconds since Unix epoch (January 1, 1970 00:00:00 UTC). + * Must be a non-negative integer. + */ + 0: number; + + /** + * Nanoseconds within the second specified by index 0. + * Must be in the range [0, 999999999]. + */ + 1: number; + + /** + * Optional total nanoseconds since Unix epoch. + * When provided, this should be equivalent to (this[0] * 1e9) + this[1]. + * + * @remarks + * This field may be used for more efficient time calculations or when + * working with systems that natively use nanosecond timestamps. + */ + // unixNano?: number; +} + +/** + * Union type representing all valid time input formats accepted by OpenTelemetry APIs. + * + * @remarks + * - `IOTelHrTime`: High-resolution time with nanosecond precision + * - `number`: Milliseconds since Unix epoch (JavaScript Date.now() format) + * - `Date`: JavaScript Date object + * + * @example + * ```typescript + * // All of these are valid time inputs: + * const hrTime: OTelTimeInput = [1609459200, 500000000]; + * const msTime: OTelTimeInput = Date.now(); + * const dateTime: OTelTimeInput = new Date(); + * + * span.addEvent("event", {}, hrTime); + * span.addEvent("event", {}, msTime); + * span.addEvent("event", {}, dateTime); + * ``` + * + * @since 3.4.0 + */ +export type OTelTimeInput = IOTelHrTime | number | Date; diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelAttributeLimits.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelAttributeLimits.ts new file mode 100644 index 000000000..a2392d696 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelAttributeLimits.ts @@ -0,0 +1,71 @@ + + +/** + * Configuration interface for OpenTelemetry attribute limits. + * These limits help control the size and number of attributes to prevent + * excessive memory usage and ensure consistent performance. + * + * @example + * ```typescript + * const limits: IOTelAttributeLimits = { + * attributeCountLimit: 128, // Maximum 128 attributes + * attributeValueLengthLimit: 4096 // Maximum 4KB per attribute value + * }; + * ``` + * + * @remarks + * When limits are exceeded: + * - Additional attributes beyond `attributeCountLimit` are dropped + * - Attribute values longer than `attributeValueLengthLimit` are truncated + * - The behavior may vary based on the specific implementation + * + * @since 3.4.0 + */ +export interface IOTelAttributeLimits { + /** + * Maximum allowed length for attribute values in characters. + * + * @remarks + * - Values longer than this limit will be truncated + * - Applies to string attribute values only + * - Numeric and boolean values are not affected by this limit + * - Array values have this limit applied to each individual element + * + * @defaultValue 4096 + * + * @example + * ```typescript + * // If attributeValueLengthLimit is 100: + * span.setAttribute("description", "a".repeat(200)); // Will be truncated to 100 characters + * span.setAttribute("count", 12345); // Not affected (number) + * span.setAttribute("enabled", true); // Not affected (boolean) + * ``` + */ + attributeValueLengthLimit?: number; + + /** + * Maximum number of attributes allowed per telemetry item. + * + * @remarks + * - Attributes added beyond this limit will be dropped + * - The order of attributes matters; earlier attributes take precedence + * - This limit applies to the total count of attributes, regardless of their type + * - Inherited or default attributes count toward this limit + * + * @defaultValue 128 + * + * @example + * ```typescript + * // If attributeCountLimit is 5: + * span.setAttributes({ + * "attr1": "value1", // Kept + * "attr2": "value2", // Kept + * "attr3": "value3", // Kept + * "attr4": "value4", // Kept + * "attr5": "value5", // Kept + * "attr6": "value6" // Dropped (exceeds limit) + * }); + * ``` + */ + attributeCountLimit?: number; +} \ No newline at end of file diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelConfig.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelConfig.ts new file mode 100644 index 000000000..ce6269f1d --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelConfig.ts @@ -0,0 +1,52 @@ +import { IOTelErrorHandlers } from "./IOTelErrorHandlers"; +import { ITraceCfg } from "./ITraceCfg"; + +/** + * OpenTelemetry configuration interface + * Provides configuration specific to the OpenTelemetry extensions + */ +export interface IOTelConfig { + /** + * Configuration interface for OpenTelemetry tracing functionality. + * This interface contains all the settings that control how traces are created, + * processed, and managed within the OpenTelemetry system. + * + * @example + * ```typescript + * const traceCfg: ITraceCfg = { + * serviceName: "my-service", + * generalLimits: { + * attributeCountLimit: 128, + * attributeValueLengthLimit: 4096 + * }, + * spanLimits: { + * attributeCountLimit: 128, + * linkCountLimit: 128, + * eventCountLimit: 128 + * } + * }; + * ``` + * + * @since 3.4.0 + */ + traceCfg?: ITraceCfg; + + /** + * Error handlers for OpenTelemetry operations. + * This interface allows you to specify custom error handling logic for various + * OpenTelemetry components, enabling better control over how errors are managed + * within the OpenTelemetry system. + * + * @see {@link IOTelErrorHandlers} + * + * @example + * ```typescript + * const errorHandlers: IOTelErrorHandlers = { + * attribError: (message, key, value) => { + * console.warn(`Attribute error for ${key}:`, message); + * } + * }; + * ``` + */ + errorHandlers?: IOTelErrorHandlers; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelErrorHandlers.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelErrorHandlers.ts new file mode 100644 index 000000000..0d8a72d52 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelErrorHandlers.ts @@ -0,0 +1,195 @@ + + +/** + * Configuration interface for OpenTelemetry error handling callbacks. + * Provides hooks to customize how different types of errors and diagnostic + * messages are handled within the OpenTelemetry system. + * + * @example + * ```typescript + * const errorHandlers: IOTelErrorHandlers = { + * attribError: (message, key, value) => { + * console.warn(`Attribute error for ${key}:`, message); + * }, + * spanError: (message, spanName) => { + * logger.error(`Span ${spanName} error:`, message); + * }, + * warn: (message) => { + * logger.warn(message); + * }, + * error: (message) => { + * logger.error(message); + * } + * }; + * ``` + * + * @remarks + * If handlers are not provided, default behavior will be used: + * - `attribError`: Throws an `OTelInvalidAttributeError` + * - `spanError`: Logs to console or calls warn handler + * - `debug`: Logs to console.log + * - `warn`: Logs to console.warn + * - `error`: Logs to console.error + * - `notImplemented`: Logs to console.error + * + * @since 3.4.0 + */ +export interface IOTelErrorHandlers { + /** + * Handles attribute-related errors, such as invalid attribute values or keys. + * Called when an attribute operation fails validation or processing. + * + * @param message - Descriptive error message explaining what went wrong + * @param key - The attribute key that caused the error + * @param value - The attribute value that caused the error (may be of any type) + * + * @remarks + * Common scenarios that trigger this handler: + * - Invalid attribute key format + * - Attribute value exceeds length limits + * - Unsupported attribute value type + * - Attribute count exceeds limits + * + * @default Throws an `OTelInvalidAttributeError` + * + * @example + * ```typescript + * attribError: (message, key, value) => { + * metrics.increment('otel.attribute.errors', { key, type: typeof value }); + * logger.warn(`Attribute ${key} rejected: ${message}`); + * } + * ``` + */ + attribError?: (message: string, key: string, value: any) => void; + + /** + * Handles span-related errors that occur during span operations. + * Called when a span operation fails or encounters an unexpected condition. + * + * @param message - Descriptive error message explaining the span error + * @param spanName - The name of the span that encountered the error + * + * @remarks + * Common scenarios that trigger this handler: + * - Span operation called on an ended span + * - Invalid span configuration + * - Span processor errors + * - Context propagation failures + * + * @default Logs to console or calls the warn handler + * + * @example + * ```typescript + * spanError: (message, spanName) => { + * metrics.increment('otel.span.errors', { span_name: spanName }); + * logger.error(`Span operation failed for "${spanName}": ${message}`); + * } + * ``` + */ + spanError?: (message: string, spanName: string) => void; + + /** + * Handles debug-level diagnostic messages. + * Used for detailed troubleshooting information that is typically + * only relevant during development or when diagnosing issues. + * + * @param message - Debug message to be handled + * + * @remarks + * Debug messages are typically: + * - Verbose operational details + * - Internal state information + * - Performance metrics + * - Development-time diagnostics + * + * @default Logs to console.log + * + * @example + * ```typescript + * debug: (message) => { + * if (process.env.NODE_ENV === 'development') { + * console.debug('[OTel Debug]', message); + * } + * } + * ``` + */ + debug?: (message: string) => void; + + /** + * Handles warning-level messages for non-fatal issues. + * Used for conditions that are unusual but don't prevent continued operation. + * + * @param message - Warning message to be handled + * + * @remarks + * Warning scenarios include: + * - Configuration issues that fall back to defaults + * - Performance degradation + * - Deprecated API usage + * - Resource limit approaches + * + * @default Logs to console.warn + * + * @example + * ```typescript + * warn: (message) => { + * logger.warn('[OTel Warning]', message); + * metrics.increment('otel.warnings'); + * } + * ``` + */ + warn?: (message: string) => void; + + /** + * Handles general error conditions that may affect functionality. + * Used for significant errors that should be investigated but may not be fatal. + * + * @param message - Error message to be handled + * + * @remarks + * Error scenarios include: + * - Failed network requests + * - Configuration validation failures + * - Resource allocation failures + * - Unexpected runtime conditions + * + * @default Logs to console.error + * + * @example + * ```typescript + * error: (message) => { + * logger.error('[OTel Error]', message); + * errorReporting.captureException(new Error(message)); + * } + * ``` + */ + error?: (message: string) => void; + + /** + * Handles errors related to unimplemented functionality. + * Called when a method or feature is not yet implemented or is intentionally + * disabled in the current configuration. + * + * @param message - Message describing the unimplemented functionality + * + * @remarks + * Common scenarios: + * - Placeholder methods that haven't been implemented + * - Features disabled in the current build + * - Platform-specific functionality not available + * - Optional features not included in the configuration + * + * @default Logs to console.error + * + * @example + * ```typescript + * notImplemented: (message) => { + * logger.warn(`[OTel Not Implemented] ${message}`); + * if (process.env.NODE_ENV === 'development') { + * console.trace('Not implemented method called'); + * } + * } + * ``` + */ + notImplemented?: (message: string) => void; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelSpanLimits.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelSpanLimits.ts new file mode 100644 index 000000000..38abb2b97 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/IOTelSpanLimits.ts @@ -0,0 +1,128 @@ +// import { IOTelAttributeLimits } from "./IOTelAttributeLimits"; + +// /** +// * Configuration interface for OpenTelemetry span-specific limits. +// * Extends the general attribute limits with additional constraints specific to spans, +// * including limits on events, links, and their associated attributes. +// * +// * @example +// * ```typescript +// * const spanLimits: IOTelSpanLimits = { +// * // Inherited from IOTelAttributeLimits +// * attributeCountLimit: 128, +// * attributeValueLengthLimit: 4096, +// * +// * // Span-specific limits +// * linkCountLimit: 128, +// * eventCountLimit: 128, +// * attributePerEventCountLimit: 32, +// * attributePerLinkCountLimit: 32 +// * }; +// * ``` +// * +// * @remarks +// * These limits help prevent spans from consuming excessive memory and ensure +// * consistent performance even when dealing with complex traces that have many +// * events, links, or attributes. +// * +// * @since 3.4.0 +// */ +// export interface IOTelSpanLimits extends IOTelAttributeLimits { + +// /** +// * Maximum number of links allowed per span. +// * +// * @remarks +// * - Links added beyond this limit will be dropped +// * - Links are typically added at span creation time +// * - Each link represents a causal relationship with another span +// * - Links added after creation may be subject to additional restrictions +// * +// * @defaultValue 128 +// * +// * @example +// * ```typescript +// * const span = tracer.startSpan("operation", { +// * links: [ +// * { context: relatedSpanContext1 }, +// * { context: relatedSpanContext2 }, +// * // ... up to linkCountLimit links +// * ] +// * }); +// * ``` +// */ +// linkCountLimit?: number; + +// /** +// * Maximum number of events allowed per span. +// * +// * @remarks +// * - Events added beyond this limit will be dropped +// * - Events are typically used to mark significant points during span execution +// * - Each event can have its own set of attributes (limited by attributePerEventCountLimit) +// * - Events are ordered chronologically within the span +// * +// * @defaultValue 128 +// * +// * @example +// * ```typescript +// * // If eventCountLimit is 3: +// * span.addEvent("started"); // Kept +// * span.addEvent("processing"); // Kept +// * span.addEvent("validation"); // Kept +// * span.addEvent("completed"); // Dropped (exceeds limit) +// * ``` +// */ +// eventCountLimit?: number; + +// /** +// * Maximum number of attributes allowed per span event. +// * +// * @remarks +// * - This limit applies to each individual event within a span +// * - Attributes added to events beyond this limit will be dropped +// * - This is separate from the span's own attribute limits +// * - Event attributes are useful for providing context about what happened at that point in time +// * +// * @defaultValue 32 +// * +// * @example +// * ```typescript +// * // If attributePerEventCountLimit is 2: +// * span.addEvent("user_action", { +// * "action": "click", // Kept +// * "element": "button", // Kept +// * "timestamp": "12345" // Dropped (exceeds per-event limit) +// * }); +// * ``` +// */ +// attributePerEventCountLimit?: number; + +// /** +// * Maximum number of attributes allowed per span link. +// * +// * @remarks +// * - This limit applies to each individual link within a span +// * - Attributes added to links beyond this limit will be dropped +// * - This is separate from the span's own attribute limits +// * - Link attributes provide additional context about the relationship between spans +// * +// * @defaultValue 32 +// * +// * @example +// * ```typescript +// * // If attributePerLinkCountLimit is 2: +// * const span = tracer.startSpan("operation", { +// * links: [{ +// * context: relatedSpanContext, +// * attributes: { +// * "relationship": "follows", // Kept +// * "correlation_id": "abc123", // Kept +// * "priority": "high" // Dropped (exceeds per-link limit) +// * } +// * }] +// * }); +// * ``` +// */ +// attributePerLinkCountLimit?: number; +// } diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/ITraceCfg.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/ITraceCfg.ts new file mode 100644 index 000000000..c1e3a8a9b --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/config/ITraceCfg.ts @@ -0,0 +1,82 @@ +import { IOTelAttributeLimits } from "./IOTelAttributeLimits"; + +/** + * Configuration interface for OpenTelemetry compatible tracing functionality. + * This interface contains all the settings that control how traces are created, + * processed, and managed within the OpenTelemetry system. + * + * @example + * ```typescript + * const traceCfg: ITraceCfg = { + * serviceName: "my-service", + * generalLimits: { + * attributeCountLimit: 128, + * attributeValueLengthLimit: 4096 + * }, + * spanLimits: { + * attributeCountLimit: 128, + * linkCountLimit: 128, + * eventCountLimit: 128 + * } + * }; + * ``` + * + * @since 3.4.0 + */ +export interface ITraceCfg { + /** + * Global attribute limits that apply to all telemetry items. + * These limits help prevent excessive memory usage and ensure consistent + * behavior across different telemetry types. + * + * @remarks + * These limits are inherited by more specific configurations unless overridden. + * For example, spans will use these limits unless `spanLimits` specifies different values. + */ + generalLimits?: IOTelAttributeLimits; + + // /** + // * Specific limits that apply only to spans. + // * These limits override the general limits for span-specific properties. + // * + // * @remarks + // * Includes limits for attributes, events, links, and their associated attributes. + // * This allows for fine-tuned control over span size and complexity. + // */ + // spanLimits?: IOTelSpanLimits; + + // idGenerator?: IOTelIdGenerator; + + // logRecordProcessors?: LogRecordProcessor[]; + // metricReader: IMetricReader; + // views: ViewOptions[]; + // instrumentations: (Instrumentation | Instrumentation[])[]; + // resource: Resource; + // resourceDetectors: Array; + + /** + * The name of the service generating telemetry data. + * This name will be included in all telemetry items as a resource attribute. + * + * @remarks + * The service name is crucial for identifying and filtering telemetry data + * in observability systems. It should be consistent across all instances + * of the same service. + * + * @example + * ```typescript + * serviceName: "user-authentication-service" + * ``` + */ + serviceName?: string; + + // spanProcessors?: SpanProcessor[]; + // traceExporter: SpanExporter; + + /** + * A flag that indicates whether the tracing (creating of a "trace" event) should be suppressed + * when a {@link IOTelSpan} ends and the span {@link IOTelSpan#isRecording | isRecording} is true. + * This value is also inherited by spans when they are created. + */ + suppressTracing?: boolean; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpan.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpan.ts new file mode 100644 index 000000000..468f30ce7 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpan.ts @@ -0,0 +1,487 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IAttributeContainer, IDistributedTraceContext } from "../../../applicationinsights-core-js"; +import { IOTelAttributes, OTelAttributeValue } from "../IOTelAttributes"; +import { OTelException } from "../IOTelException"; +import { OTelTimeInput } from "../IOTelHrTime"; +import { IOTelSpanStatus } from "./IOTelSpanStatus"; + +/** + * Provides an OpenTelemetry compatible interface for spans conforming to the OpenTelemetry API specification (v1.9.0). + * + * A span represents an operation within a trace and is the fundamental unit of work in distributed tracing. + * Spans can be thought of as a grouping mechanism for a set of operations that are executed as part of + * a single logical unit of work, providing timing information and contextual data about the operation. + * + * Spans form a tree structure within a trace, with a single root span that may have zero or more child spans, + * which in turn may have their own children. This hierarchical structure allows for detailed analysis of + * complex, multi-step operations across distributed systems. + * + * @since 3.4.0 + * + * @remarks + * - All spans created by this library implement the ISpan interface and extend the IReadableSpan interface + * - Spans should be ended by calling `end()` when the operation completes + * - Once ended, spans should generally not be used for further operations + * - Spans automatically track timing information from creation to end + * + * @example + * ```typescript + * // Basic span usage + * const span = tracer.startSpan('user-authentication'); + * span.setAttribute('user.id', '12345'); + * span.setAttribute('auth.method', 'oauth2'); + * + * try { + * const result = await authenticateUser(); + * span.setStatus({ code: SpanStatusCode.OK }); + * span.setAttribute('auth.success', true); + * } catch (error) { + * span.recordException(error); + * span.setStatus({ + * code: SpanStatusCode.ERROR, + * message: 'Authentication failed' + * }); + * } finally { + * span.end(); + * } + * ``` + */ +export interface IOTelSpan { + /** + * Returns the span context object associated with this span. + * + * The span context is an immutable, serializable identifier that uniquely identifies + * this span within a trace. It contains the trace ID, span ID, and trace flags that + * can be used to create new child spans or propagate trace context across process boundaries. + * + * The returned span context remains valid even after the span has ended, making it + * useful for asynchronous operations and cross-service communication. + * + * @returns The immutable span context associated with this span + * + * @remarks + * - The span context is the primary mechanism for trace propagation + * - Context can be serialized and transmitted across network boundaries + * - Contains trace ID (unique to the entire trace) and span ID (unique to this span) + * + * @example + * ```typescript + * const span = tracer.startSpan('parent-operation'); + * const spanContext = span.spanContext(); + * + * // Use context to create child spans in other parts of the system + * const childSpan = tracer.startSpan('child-operation', { + * parent: spanContext + * }); + * + * // Context can be serialized for cross-service propagation + * const traceId = spanContext.traceId; + * const spanId = spanContext.spanId; + * ``` + */ + spanContext(): IDistributedTraceContext; + + /** + * Sets a single attribute on the span with the specified key and value. + * + * Attributes provide contextual information about the operation represented by the span. + * They are key-value pairs that help with filtering, grouping, and understanding spans + * in trace analysis tools. Attributes should represent meaningful properties of the operation. + * + * @param key - The attribute key, should be descriptive and follow naming conventions + * @param value - The attribute value; null or undefined values are invalid and result in undefined behavior + * + * @returns This span instance for method chaining + * + * @remarks + * - Attribute keys should follow semantic conventions when available + * - Common attributes include service.name, http.method, db.statement, etc. + * - Setting null or undefined values is invalid and may cause unexpected behavior + * - Attributes set after span creation don't affect sampling decisions + * + * @example + * ```typescript + * const span = tracer.startSpan('http-request'); + * + * // Set individual attributes with descriptive keys + * span.setAttribute('http.method', 'POST') + * .setAttribute('http.url', 'https://api.example.com/users') + * .setAttribute('http.status_code', 201) + * .setAttribute('user.id', '12345') + * .setAttribute('request.size', 1024); + * ``` + */ + setAttribute(key: string, value: OTelAttributeValue): this; + + /** + * Sets multiple attributes on the span at once using an attributes object. + * + * This method allows efficient batch setting of multiple attributes in a single call. + * All attributes in the provided object will be added to the span, supplementing any + * existing attributes (duplicate keys will be overwritten). + * + * @param attributes - An object containing key-value pairs to set as span attributes + * + * @returns This span instance for method chaining + * + * @remarks + * - Null or undefined attribute values are invalid and will result in undefined behavior + * - More efficient than multiple `setAttribute` calls for bulk operations + * - Existing attributes with the same keys will be overwritten + * + * @example + * ```typescript + * const span = tracer.startSpan('database-query'); + * + * // Set multiple attributes efficiently + * span.setAttributes({ + * 'db.system': 'postgresql', + * 'db.name': 'user_database', + * 'db.table': 'users', + * 'db.operation': 'SELECT', + * 'db.rows_affected': 5, + * 'query.duration_ms': 15.7 + * }); + * ``` + */ + setAttributes(attributes: IOTelAttributes): this; + + /** + * The {@link IAttributeContainer | attribute container} associated with this span, providing + * advanced attribute management capabilities. Rather than using the {@link IReadableSpan#attributes} + * directly which returns a readonly {@link IOTelAttributes} map that is a snapshot of the attributes at + * the time of access, the attribute container offers methods to get, set, delete, and iterate over attributes + * with fine-grained control. + * It is recommended that you only access the {@link IReadableSpan#attributes} property sparingly due to the + * performance cost of taking a snapshot of all attributes. + */ + readonly attribContainer: IAttributeContainer; + + // /** + // * Adds an event to the span with optional attributes and timestamp. + // * + // * **Note: This method is currently not implemented and events will be dropped.** + // * + // * Events represent significant points in time during the span's execution. + // * They provide additional context about what happened during the operation, + // * such as cache hits/misses, validation steps, or other notable occurrences. + // * + // * @param name - The name of the event, should be descriptive of what occurred + // * @param attributesOrStartTime - Event attributes object, or start time if third parameter is undefined + // * @param startTime - Optional start time of the event; if not provided, current time is used + // * + // * @returns This span instance for method chaining + // * + // * @remarks + // * - **Current implementation drops events - not yet supported** + // * - Events are timestamped occurrences within a span's lifecycle + // * - Useful for marking significant points like cache hits, retries, or validation steps + // * - Should not be used for high-frequency events due to performance impact + // * + // * @example + // * ```typescript + // * const span = tracer.startSpan('user-registration'); + // * + // * // Add events to mark significant points + // * span.addEvent('validation.started') + // * .addEvent('validation.completed', { + // * 'validation.result': 'success', + // * 'validation.duration_ms': 23 + // * }) + // * .addEvent('database.save.started') + // * .addEvent('database.save.completed', { + // * 'db.rows_affected': 1 + // * }); + // * ``` + // */ + // addEvent(name: string, attributesOrStartTime?: IOTelAttributes | OTelTimeInput, startTime?: OTelTimeInput): this; + + // /** + // * Adds a single link to the span connecting it to another span. + // * + // * **Note: This method is currently not implemented and links will be dropped.** + // * + // * Links establish relationships between spans that are not in a typical parent-child + // * relationship. They are useful for connecting spans across different traces or + // * for representing batch operations where multiple spans are related but not nested. + // * + // * @param link - The link object containing span context and optional attributes + // * + // * @returns This span instance for method chaining + // * + // * @remarks + // * - **Current implementation drops links - not yet supported** + // * - Links added after span creation do not affect sampling decisions + // * - Prefer adding links during span creation when possible + // * - Useful for batch operations, fan-out scenarios, or cross-trace relationships + // * + // * @example + // * ```typescript + // * const span = tracer.startSpan('batch-processor'); + // * + // * // Link to related spans from a batch operation + // * span.addLink({ + // * context: relatedSpan.spanContext(), + // * attributes: { + // * 'link.type': 'batch_item', + // * 'batch.index': 1 + // * } + // * }); + // * ``` + // */ + // addLink(link: IOTelLink): this; + + // /** + // * Adds multiple links to the span in a single operation. + // * + // * **Note: This method is currently not implemented and links will be dropped.** + // * + // * This is an efficient way to establish multiple relationships between this span + // * and other spans. Particularly useful for batch operations, fan-out scenarios, + // * or when a single operation needs to reference multiple related operations. + // * + // * @param links - An array of link objects to add to the span + // * + // * @returns This span instance for method chaining + // * + // * @remarks + // * - **Current implementation drops links - not yet supported** + // * - More efficient than multiple `addLink` calls for bulk operations + // * - Links added after span creation do not affect sampling decisions + // * - Consider span creation time linking for sampling-sensitive scenarios + // * + // * @example + // * ```typescript + // * const span = tracer.startSpan('aggregate-results'); + // * + // * // Link to multiple related spans from parallel operations + // * span.addLinks([ + // * { + // * context: span1.spanContext(), + // * attributes: { 'operation.type': 'data_fetch', 'source': 'database' } + // * }, + // * { + // * context: span2.spanContext(), + // * attributes: { 'operation.type': 'data_fetch', 'source': 'cache' } + // * }, + // * { + // * context: span3.spanContext(), + // * attributes: { 'operation.type': 'data_transform', 'stage': 'preprocessing' } + // * } + // * ]); + // * ``` + // */ + // addLinks(links: IOTelLink[]): this; + + /** + * Sets the status of the span to indicate the success or failure of the operation. + * + * The span status provides a standardized way to indicate whether the operation + * completed successfully, encountered an error, or is in an unknown state. + * This status is used by observability tools to provide meaningful insights + * about system health and operation outcomes. + * + * @param status - The status object containing code and optional message + * + * @returns This span instance for method chaining + * + * @remarks + * - Default status is UNSET until explicitly set + * - Setting status overrides any previous status values + * - ERROR status should be accompanied by a descriptive message when possible + * - Status should reflect the final outcome of the operation + * + * @example + * ```typescript + * const span = tracer.startSpan('payment-processing'); + * + * try { + * const result = await processPayment(paymentData); + * + * // Indicate successful completion + * span.setStatus({ + * code: SpanStatusCode.OK + * }); + * + * } catch (error) { + * // Indicate operation failed + * span.setStatus({ + * code: SpanStatusCode.ERROR, + * message: 'Payment processing failed: ' + error.message + * }); + * + * span.recordException(error); + * } + * ``` + */ + setStatus(status: IOTelSpanStatus): this; + + /** + * Updates the name of the span, overriding the name provided during creation. + * + * Span names should be descriptive and represent the operation being performed. + * Updating the name can be useful when the operation's scope becomes clearer + * during execution, or when implementing generic spans that need specific naming + * based on runtime conditions. + * + * @param name - The new name for the span, should be descriptive of the operation + * + * @returns This span instance for method chaining + * + * @remarks + * - Name updates may affect sampling behavior depending on implementation + * - Choose names that are meaningful but not too specific to avoid cardinality issues + * - Follow naming conventions consistent with your observability strategy + * - Consider the impact on existing traces and dashboards when changing names + * + * @example + * ```typescript + * const span = tracer.startSpan('generic-operation'); + * + * // Update name based on runtime determination + * if (operationType === 'user-registration') { + * span.updateName('user-registration'); + * span.setAttribute('operation.type', 'registration'); + * } else if (operationType === 'user-login') { + * span.updateName('user-authentication'); + * span.setAttribute('operation.type', 'authentication'); + * } + * ``` + */ + updateName(name: string): this; + + /** + * Marks the end of the span's execution and records the end timestamp. + * + * This method finalizes the span and makes it available for export to tracing systems. + * Once ended, the span should not be used for further operations. The span's duration + * is calculated from its start time to the end time provided or current time. + * + * @param endTime - Optional end time; if not provided, current time is used + * + * @remarks + * - This method does NOT return `this` to discourage chaining after span completion + * - Ending a span has no effect on child spans, which may continue running + * - Child spans can be ended independently after their parent has ended + * - The span becomes eligible for export once ended + * - Calling end() multiple times has no additional effect + * + * @example + * ```typescript + * const span = tracer.startSpan('file-processing'); + * + * try { + * // Perform the operation + * const result = await processFile(filePath); + * + * // Record success + * span.setStatus({ code: SpanStatusCode.OK }); + * span.setAttribute('file.size', result.size); + * + * } catch (error) { + * span.recordException(error); + * span.setStatus({ + * code: SpanStatusCode.ERROR, + * message: error.message + * }); + * } finally { + * // Always end the span + * span.end(); + * // Don't use span after this point + * } + * + * // Custom end time example + * const customEndTime = Date.now() * 1000000; // nanoseconds + * span.end(customEndTime); + * ``` + */ + end(endTime?: OTelTimeInput): void; + + /** + * Returns whether this span is actively recording information. + * + * A recording span accepts and stores attributes, events, status, and other span data. + * Non-recording spans (typically due to sampling decisions) may ignore operations + * like setAttribute() to optimize performance. This method allows conditional + * logic to avoid expensive operations on non-recording spans. + * + * @returns True if the span is actively recording information, false otherwise + * + * @remarks + * - Recording status is typically determined at span creation time + * - Non-recording spans still provide valid span context for propagation + * - Use this check to avoid expensive attribute calculations for non-recording spans + * - Recording status remains constant throughout the span's lifetime + * + * @example + * ```typescript + * const span = tracer.startSpan('data-processing'); + * + * // Only perform expensive operations if span is recording + * if (span.isRecording()) { + * const metadata = await expensiveMetadataCalculation(); + * span.setAttributes({ + * 'process.metadata': JSON.stringify(metadata), + * 'process.complexity': metadata.complexity, + * 'process.estimated_duration': metadata.estimatedMs + * }); + * } + * + * // Always safe to set basic attributes + * span.setAttribute('process.started', true); + * ``` + */ + isRecording(): boolean; + + /** + * Records an exception as a span event with automatic error status handling. + * + * This method captures exception information and automatically creates a span event + * with standardized exception attributes. It's the recommended way to handle errors + * within spans, providing consistent error reporting across the application. + * + * @param exception - The exception to record; accepts string messages or Error objects + * @param time - Optional timestamp for when the exception occurred; defaults to current time + * + * @remarks + * - Automatically extracts exception type, message, and stack trace when available + * - Creates a standardized span event with exception details + * - Does NOT automatically set span status to ERROR - call setStatus() explicitly if needed + * - Exception events are useful for debugging and error analysis + * + * @example + * ```typescript + * const span = tracer.startSpan('risky-operation'); + * + * try { + * await performRiskyOperation(); + * span.setStatus({ code: SpanStatusCode.OK }); + * + * } catch (error) { + * // Record the exception details + * span.recordException(error); + * + * // Explicitly set error status + * span.setStatus({ + * code: SpanStatusCode.ERROR, + * message: 'Operation failed due to: ' + error.message + * }); + * + * // Re-throw if needed + * throw error; + * } finally { + * span.end(); + * } + * + * // Recording string exceptions + * span.recordException('Custom error message occurred'); + * + * // Recording with custom timestamp + * const errorTime = Date.now() * 1000000; // nanoseconds + * span.recordException(error, errorTime); + * ``` + */ + recordException(exception: OTelException, time?: OTelTimeInput): void; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanContext.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanContext.ts deleted file mode 100644 index 3a63557c0..000000000 --- a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanContext.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { IOTelTraceState } from "./IOTelTraceState"; - -/** - * A SpanContext represents the portion of a {@link IOTelSpan} which must be - * serialized and propagated along side of a {@link IOTelBaggage}. - */ -export interface IOTelSpanContext { - /** - * The ID of the trace that this span belongs to. It is worldwide unique - * with practically sufficient probability by being made as 16 randomly - * generated bytes, encoded as a 32 lowercase hex characters corresponding to - * 128 bits. - */ - traceId: string; - - /** - * The ID of the Span. It is globally unique with practically sufficient - * probability by being made as 8 randomly generated bytes, encoded as a 16 - * lowercase hex characters corresponding to 64 bits. - */ - spanId: string; - - /** - * Only true if the SpanContext was propagated from a remote parent. - */ - isRemote?: boolean; - - /** - * Trace flags to propagate. - * - * It is represented as 1 byte (bitmap). Bit to represent whether trace is - * sampled or not. When set, the least significant bit documents that the - * caller may have recorded trace data. A caller who does not record trace - * data out-of-band leaves this flag unset. - * - * see {@link eW3CTraceFlags} for valid flag values. - */ - traceFlags: number; - - /** - * Tracing-system-specific info to propagate. - * - * The tracestate field value is a `list` as defined below. The `list` is a - * series of `list-members` separated by commas `,`, and a list-member is a - * key/value pair separated by an equals sign `=`. Spaces and horizontal tabs - * surrounding `list-members` are ignored. There can be a maximum of 32 - * `list-members` in a `list`. - * More Info: https://www.w3.org/TR/trace-context/#tracestate-field - * - * Examples: - * Single tracing system (generic format): - * tracestate: rojo=00f067aa0ba902b7 - * Multiple tracing systems (with different formatting): - * tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE - */ - traceState?: IOTelTraceState; -} \ No newline at end of file diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanCtx.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanCtx.ts new file mode 100644 index 000000000..b71903a69 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanCtx.ts @@ -0,0 +1,59 @@ +import { IDistributedTraceContext } from "../../../JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { IOTelApi } from "../IOTelApi"; +import { IOTelAttributes } from "../IOTelAttributes"; +import { OTelTimeInput } from "../IOTelHrTime"; +import { IReadableSpan } from "./IReadableSpan"; + +/** + * The context to use for creating a Span + */ +export interface IOTelSpanCtx { + /** + * The current {@link IOTelApi} instance that is being used. + */ + api: IOTelApi; + + // /** + // * The current {@link IOTelResource} instance to use for this Span Context + // */ + // resource: IOTelResource; + + // /** + // * The current {@link IOTelInstrumentationScope} instrumentationScope instance to + // * use for this Span Context + // */ + // instrumentationScope: IOTelInstrumentationScope; + + // /** + // * The context for the current instance (not currently used) + // */ + // context?: IOTelContext; + + /* + * The current {@link IDistributedTraceContext} instance to associated with the span + * used to create the span. + */ + spanContext: IDistributedTraceContext; + + /** + * Identifies the user provided start time of the span + */ + startTime?: OTelTimeInput; + + parentSpanContext?: IDistributedTraceContext; + + attributes?: IOTelAttributes; + + // links?: IOTelLink[]; + + isRecording?: boolean; + + /** + * When the span ends this callback will be called to process the specified Span and end it + * @param span - The span to end + * @param endTime - The end time of the span + * @param duration - The duration of the span + * @returns + */ + onEnd?: (span: IReadableSpan) => void; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanOptions.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanOptions.ts new file mode 100644 index 000000000..03ab43fca --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanOptions.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OTelSpanKind } from "../../enums/trace/OTelSpanKind"; +import { IOTelAttributes } from "../IOTelAttributes"; +import { OTelTimeInput } from "../IOTelHrTime"; + +/** + * Options for creating a span. + */ +export interface IOTelSpanOptions { + /** + * The SpanKind of a span of this span, this is used to specify + * the relationship between the span and its parent span. + * @see {@link eOTelSpanKind} for possible values. + * @default eOTelSpanKind.INTERNAL + */ + kind?: OTelSpanKind; + + /** + * A span's attributes + */ + attributes?: IOTelAttributes; + + /** A manually specified start time for the created `Span` object. */ + startTime?: OTelTimeInput; + + /** The new span should be a root span. (Ignore parent from context). */ + root?: boolean; + + /** Specify whether the span should be a recording span, default is true */ + recording?: boolean; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanStatus.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanStatus.ts new file mode 100644 index 000000000..be5a628ee --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanStatus.ts @@ -0,0 +1,13 @@ +import { eOTelSpanStatusCode } from "../../enums/trace/OTelSpanStatus"; + +export interface IOTelSpanStatus { + /** + * The status code of this message. + */ + code: eOTelSpanStatusCode; + + /** + * A developer-facing error message. + */ + message?: string; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTracer.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTracer.ts new file mode 100644 index 000000000..acb5cfc38 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTracer.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IOTelSpanOptions } from "./IOTelSpanOptions"; +import { IReadableSpan } from "./IReadableSpan"; + +/** + * OpenTelemetry tracer interface for creating and managing spans within a trace. + * + * A tracer is responsible for creating spans that represent units of work within a distributed system. + * Each tracer is typically associated with a specific instrumentation library or component, + * allowing for fine-grained control over how different parts of an application generate telemetry. + * + * @example + * ```typescript + * // Get a tracer instance + * const tracer = otelApi.getTracer('my-service'); + * + * // Create a simple span + * const span = tracer.startSpan('database-query'); + * span.setAttribute('db.operation', 'SELECT'); + * span.end(); + * + * // Create an active span with automatic context management + * tracer.startActiveSpan('process-request', (span) => { + * span.setAttribute('request.id', '12345'); + * + * // Any spans created within this block will be children of this span + * processRequest(); + * + * span.end(); + * }); + * ``` + * + * @see {@link IReadableSpan} - Interface for individual spans + * @see {@link IOTelSpanOptions} - Configuration options for span creation + * + * @since 3.4.0 + */ +export interface IOTelTracer { + /** + * Creates and starts a new span without setting it as the active span in the current context. + * + * This method creates a span but does NOT modify the current execution context. + * The caller is responsible for managing the span's lifecycle, including calling `end()` + * when the operation completes. + * + * @param name - The name of the span, should be descriptive of the operation being traced + * @param options - Optional configuration for span creation (parent context, attributes, etc.) + * @param context - Optional context to use for extracting the parent span; if not provided, uses current context + * + * @returns The newly created span, or null if span creation failed + * + * @remarks + * - The returned span must be manually ended by calling `span.end()` + * - This span will not automatically become the parent for spans created in nested operations + * - Use `startActiveSpan` if you want automatic context management + * + * @example + * ```typescript + * const span = tracer.startSpan('database-operation'); + * if (span) { + * try { + * span.setAttribute('db.table', 'users'); + * span.setAttribute('db.operation', 'SELECT'); + * + * // Perform database operation + * const result = await db.query('SELECT * FROM users'); + * + * span.setAttributes({ + * 'db.rows_affected': result.length, + * 'operation.success': true + * }); + * } catch (error) { + * span.setStatus({ + * code: SpanStatusCode.ERROR, + * message: error.message + * }); + * span.recordException(error); + * } finally { + * span.end(); // Always end the span + * } + * } + * ``` + */ + startSpan(name: string, options?: IOTelSpanOptions): IReadableSpan | null; + + /** + * Creates and starts a new span, sets it as the active span in the current context, + * and executes a provided function within this context. + * + * This method creates a span, makes it active during the execution of the provided + * function, and automatically ends the span when the function completes (or throws). + * This provides automatic span lifecycle management and context propagation. + * + * @param name - The name of the span, should be descriptive of the operation being traced + * @param options - Optional configuration for span creation (parent context, attributes, etc.) + * @param fn - The function to execute within the span's active context + * + * @returns The result of executing the provided function + * + * @remarks + * - The span is automatically ended when the function completes or throws an exception + * - The span becomes the active parent for any spans created within the function + * - If the function throws an error, the span status is automatically set to ERROR + * - This is the recommended method for most tracing scenarios due to automatic lifecycle management + * - Multiple overloads available for different parameter combinations + * + * @example + * ```typescript + * // Synchronous operation with just name and function + * const result = tracer.startActiveSpan('user-service', (span) => { + * span.setAttribute('operation', 'get-user-details'); + * return { user: getUserData(), timestamp: new Date().toISOString() }; + * }); + * + * // With options + * const result2 = tracer.startActiveSpan('database-query', + * { attributes: { 'db.table': 'users' } }, + * (span) => { + * span.setAttribute('db.operation', 'SELECT'); + * return database.getUser('123'); + * } + * ); + * + * // With full context control + * const result3 = tracer.startActiveSpan('external-api', + * { attributes: { 'service.name': 'payment-api' } }, + * currentContext, + * async (span) => { + * try { + * const response = await fetch('/api/payment'); + * span.setAttributes({ + * 'http.status_code': response.status, + * 'operation.success': response.ok + * }); + * return response.json(); + * } catch (error) { + * span.setAttribute('error.type', error.constructor.name); + * throw error; // Error automatically recorded + * } + * } + * ); + * ``` + */ + startActiveSpan unknown>(name: string, fn: F): ReturnType; + startActiveSpan unknown>(name: string, options: IOTelSpanOptions,fn: F ): ReturnType; + } diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTracerProvider.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTracerProvider.ts new file mode 100644 index 000000000..8e4430fb9 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTracerProvider.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IPromise } from "@nevware21/ts-async"; +import { IOTelTracer } from "./IOTelTracer"; + +export declare interface IOTelTracerOptions { + /** + * The schemaUrl of the tracer or instrumentation library + */ + schemaUrl?: string; +} + +/** + * OpenTelemetry Trace API for getting tracers. + * This provides the standard OpenTelemetry trace API entry point. + */ +export interface IOTelTracerProvider { + /** + * 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; + + /** + * Forces the tracer provider to flush any buffered data. + * @returns A promise that resolves when the flush is complete. + */ + forceFlush?: () => IPromise | void; + + /** + * Shuts down the tracer provider and releases any resources. + * @returns A promise that resolves when the shutdown is complete. + */ + shutdown?: () => IPromise | void; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IReadableSpan.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IReadableSpan.ts new file mode 100644 index 000000000..020b57f52 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IReadableSpan.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IDistributedTraceContext } from "../../../JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { OTelSpanKind } from "../../enums/trace/OTelSpanKind"; +import { IOTelAttributes } from "../IOTelAttributes"; +import { IOTelHrTime } from "../IOTelHrTime"; +import { IOTelSpan } from "./IOTelSpan"; +import { IOTelSpanStatus } from "./IOTelSpanStatus"; + +/** + * Provides an OpenTelemetry compatible Interface for the Open Telemetry Sdk-Trace-Base (1.8.0 and 2.0.0) ReadableSpan type. + * + * The IReadableSpan interface is used to represent a span that can be read and exported, while the OpenTelemetry + * specification defines a ReadableSpan as a Span that has been ended and is ready to be exported. By default all + * spans created by this library implement the IReadableSpan interface which also extends the {@link IOTelSpan} interface. + * + * This interface is defined to provide compatibility with exporters defined by the OpenTelemetry Trace SDK. + * @since 3.4.0 + */ +export interface IReadableSpan extends IOTelSpan { + + /** + * The span's unique identifier. + */ + readonly name: string; + + /** + * Identifies the type (or kind) that this span is representing. + */ + readonly kind: OTelSpanKind; + readonly spanContext: () => IDistributedTraceContext; + readonly parentSpanId?: string; + readonly parentSpanContext?: IDistributedTraceContext; + readonly startTime: IOTelHrTime; + readonly endTime: IOTelHrTime; + readonly status: IOTelSpanStatus; + + /** + * Provides a snapshot of the span's attributes at the time this span was ended. + * @returns A read-only snapshot of the span's attributes + * @remarks + * It is recommended that you only access this property sparingly due to the + * performance cost of taking a snapshot of all attributes. + */ + readonly attributes: IOTelAttributes; + // readonly links: IOTelLink[]; + // readonly events: IOTelTimedEvent[]; + readonly duration: IOTelHrTime; + readonly ended: boolean; + // readonly resource: IOTelResource; + // readonly instrumentationScope: IOTelInstrumentationScope; + readonly droppedAttributesCount: number; + // readonly droppedEventsCount: number; + // readonly droppedLinksCount: number; + } diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/ITraceApi.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/ITraceApi.ts new file mode 100644 index 000000000..ab4afff50 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/ITraceApi.ts @@ -0,0 +1,47 @@ +import { IDistributedTraceContext } from "../../../JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { ISpanScope } from "../../../JavaScriptSDK.Interfaces/ITraceProvider"; +import { IOTelTracer } from "./IOTelTracer"; +import { IOTelTracerOptions } from "./IOTelTracerProvider"; +import { IReadableSpan } from "./IReadableSpan"; + +/** + * ITraceApi provides an interface definition which is simular to the OpenTelemetry TraceAPI + */ +export interface ITraceApi { + /** + * Returns a Tracer, creating one if one with the given name and version + * if one has not already created. + * + * @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; + + /** + * Wrap the given {@link IDistributedTraceContext} in a new non-recording {@link IReadableSpan} + * + * @param spanContext - The {@link IDistributedTraceContext} to be wrapped + * @returns a new non-recording {@link IReadableSpan} with the provided context + */ + wrapSpanContext(spanContext: IDistributedTraceContext): IReadableSpan; + + /** + * Returns true if this {@link IDistributedTraceContext} is valid. + * @return true if this {@link IDistributedTraceContext} is valid. + */ + isSpanContextValid(spanContext: IDistributedTraceContext): boolean; + + /** + * Gets the span from the current context, if one exists. + */ + getActiveSpan(): IReadableSpan | undefined | null; + + /** + * Set or clear the current active span. + * @param span - The span to set as the active span, or null/undefined to clear the active span. + * @return An ISpanScope instance returned by the host, or void if there is no defined host. + */ + setActiveSpan(span: IReadableSpan | undefined | null): ISpanScope | undefined | null; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/noop/noopHelpers.ts b/shared/AppInsightsCore/src/OpenTelemetry/noop/noopHelpers.ts new file mode 100644 index 000000000..0b34a2a27 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/noop/noopHelpers.ts @@ -0,0 +1,15 @@ + + +/** + * A simple function that does nothing and returns the current this (if any). + * @returns + */ +export function _noopThis(this: T): T { + return this; +} + +/** + * A simple function that does nothing and returns undefined. + */ +export function _noopVoid(this: T): void { +} \ No newline at end of file diff --git a/shared/AppInsightsCore/src/OpenTelemetry/otelApi.ts b/shared/AppInsightsCore/src/OpenTelemetry/otelApi.ts new file mode 100644 index 000000000..fec209d87 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/otelApi.ts @@ -0,0 +1,21 @@ +import { ILazyValue, objDefineProps } from "@nevware21/ts-utils"; +import { setProtoTypeName } from "../JavaScriptSDK/HelperFuncs"; +import { IOTelApi } from "./interfaces/IOTelApi"; +import { IOTelApiCtx } from "./interfaces/IOTelApiCtx"; +import { ITraceApi } from "./interfaces/trace/ITraceApi"; +import { _createTraceApi } from "./trace/traceApi"; +import { _createTracerProvider } from "./trace/tracerProvider"; + +export function createOTelApi(otelApiCtx: IOTelApiCtx): IOTelApi { + let _traceApi: ILazyValue; + + let otelApi = setProtoTypeName(objDefineProps(_createTracerProvider(otelApiCtx.host) as IOTelApi, { + cfg: { g: () => otelApiCtx.host.config }, + trace: { g: () => _traceApi.v }, + host: { g: () => otelApiCtx.host } + }), "OTelApi"); + + _traceApi = _createTraceApi(otelApi); + + return otelApi +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/span.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/span.ts new file mode 100644 index 000000000..102973879 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/span.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + ILazyValue, dumpObj, getDeferred, isNullOrUndefined, isString, objDefineProps, objFreeze, objIs, objKeys, perfNow +} from "@nevware21/ts-utils"; +import { setProtoTypeName, updateProtoTypeName } from "../../JavaScriptSDK/HelperFuncs"; +import { STR_EMPTY, UNDEFINED_VALUE } from "../../JavaScriptSDK/InternalConstants"; +import { IAttributeContainer } from "../attribute/IAttributeContainer"; +import { addAttributes, createAttributeContainer } from "../attribute/attributeContainer"; +import { OTelSpanKind, eOTelSpanKind } from "../enums/trace/OTelSpanKind"; +import { eOTelSpanStatusCode } from "../enums/trace/OTelSpanStatus"; +import { isAttributeValue } from "../helpers/attributeHelpers"; +import { handleAttribError, handleNotImplemented, handleSpanError, handleWarn } from "../helpers/handleErrors"; +import { hrTime, hrTimeDuration, hrTimeToMilliseconds, millisToHrTime, timeInputToHrTime, zeroHrTime } from "../helpers/timeHelpers"; +import { IOTelAttributes } from "../interfaces/IOTelAttributes"; +import { OTelException } from "../interfaces/IOTelException"; +import { IOTelHrTime, OTelTimeInput } from "../interfaces/IOTelHrTime"; +import { IOTelSpanCtx } from "../interfaces/trace/IOTelSpanCtx"; +import { IOTelSpanStatus } from "../interfaces/trace/IOTelSpanStatus"; +import { IReadableSpan } from "../interfaces/trace/IReadableSpan"; + +export function createSpan(spanCtx: IOTelSpanCtx, orgName: string, kind: OTelSpanKind): IReadableSpan { + let otelCfg = spanCtx.api.cfg; + let perfStartTime: number = perfNow(); + let spanContext = spanCtx.spanContext; + let attributes: ILazyValue; + let isEnded = false; + let errorHandlers = otelCfg.errorHandlers || {}; + let spanStartTime: ILazyValue = getDeferred(() => { + if (isNullOrUndefined(spanCtx.startTime)) { + return hrTime(perfStartTime); + } + + return timeInputToHrTime(spanCtx.startTime); + }); + let spanEndTime: IOTelHrTime | undefined; + let spanDuration: IOTelHrTime; + let spanStatus: IOTelSpanStatus | undefined; + // let links: IOTelLink[] = []; + // let events: IOTelTimedEvent[] = []; + let localDroppedAttributes = 0; + let localContainer: IAttributeContainer = null;; + // let droppedEvents = 0; + // let droppedLinks = 0; + let isRecording = spanCtx.isRecording !== false; + if (otelCfg.traceCfg && otelCfg.traceCfg.suppressTracing) { + // Automatically disable the span from recording + isRecording = false; + } + + let spanName = orgName || STR_EMPTY; + if (isRecording) { + attributes = getDeferred(() => createAttributeContainer(otelCfg, spanName, spanCtx.attributes)); + } + + function _handleIsEnded(operation: string, extraMsg?: string): boolean { + if (isEnded) { + handleSpanError(errorHandlers, "Span {traceID: " + spanContext.traceId + ", spanId: " + spanContext.spanId + "} has ended - operation [" + operation + "] unsuccessful" + (extraMsg ? (" - " + extraMsg) : STR_EMPTY) + ".", spanName); + } + + return isEnded; + } + function _toString() { + return "ReadableSpan (\"" + spanName + "\")" + } + + let theSpan: IReadableSpan = setProtoTypeName({ + spanContext: () => spanContext, + setAttribute: (key: string, value: any) => { + let message: string; + + if (value !== null && !_handleIsEnded("setAttribute") && isRecording) { + if (!key || key.length === 0) { + message = "Invalid attribute key: " + dumpObj(key); + } else if (!isAttributeValue(value)) { + message = "Invalid attribute value: " + dumpObj(value); + } + + if (message) { + handleAttribError(errorHandlers, message, key, value); + localDroppedAttributes++; + } else if (isRecording && attributes){ + attributes.v.set(key, value); + } else { + localDroppedAttributes++; + } + } else { + localDroppedAttributes++; + } + + return theSpan; + }, + setAttributes: (attrs: IOTelAttributes) => { + if (!_handleIsEnded("setAttributes") && isRecording && attributes) { + addAttributes(attributes.v, attrs); + } else { + localDroppedAttributes += (objKeys(attrs).length || 0); + } + + return theSpan; + }, + // addEvent: (name: string, attributesOrStartTime?: IOTelAttributes | OTelTimeInput, startTime?: OTelTimeInput) => { + // droppedEvents++; + // if(!_handleIsEnded("addEvent") && isRecording) { + // handleWarn(errorHandlers, "Span.addEvent: " + name + " not added - No events allowed"); + // } + + // return theSpan; + // }, + // addLink: (link: any) => { + // droppedLinks++; + // if(!_handleIsEnded("addEvent") && isRecording) { + // handleWarn(errorHandlers, "Span.addLink: " + link + " not added - No links allowed"); + // } + + // return theSpan; + // }, + // addLinks: (links: any[]) => { + // droppedLinks += links.length; + // if (!_handleIsEnded("addLinks") && isRecording) { + // handleWarn(errorHandlers, "Span.addLinks: " + links + " not added - No links allowed"); + // } + + // return theSpan; + // }, + setStatus: (newStatus: IOTelSpanStatus) => { + if (!_handleIsEnded("setStatus")) { + spanStatus = newStatus; + if (!isNullOrUndefined(spanStatus) && !isNullOrUndefined(spanStatus.message) && !isString(spanStatus.message)) { + spanStatus.message = dumpObj(spanStatus.message); + } + } + + return theSpan; + }, + updateName: (name: string) => { + if (!_handleIsEnded("updateName") && !objIs(spanName, name)) { + spanName = name; + updateProtoTypeName(theSpan, _toString()); + } + + return theSpan; + }, + end: (endTime?: OTelTimeInput) => { + let calcDuration: number; + if (!_handleIsEnded("end", "You can only call end once")) { + try { + if (!isNullOrUndefined(endTime)) { + // User provided an end time + spanEndTime = timeInputToHrTime(endTime); + spanDuration = hrTimeDuration(spanStartTime.v, spanEndTime); + calcDuration = hrTimeToMilliseconds(spanDuration); + } else { + let perfEndTime = perfNow(); + calcDuration = perfEndTime - perfStartTime; + spanDuration = millisToHrTime(calcDuration); + spanEndTime = hrTime(perfEndTime); + } + + if (calcDuration < 0) { + handleWarn(errorHandlers, "Span.end: duration is negative - startTime > endTime. Setting duration to 0 ms"); + spanDuration = zeroHrTime(); + spanEndTime = spanStartTime.v; + } + + // if (droppedEvents > 0) { + // handleWarn(errorHandlers, "Droped " + droppedEvents + " events"); + // } + + // We don't mark as ended until after the onEnd callback to ensure that it can + // still read / change the span if required as well as ensuring that the returned + // value for isRecording is correct. + spanCtx.onEnd && spanCtx.onEnd(theSpan); + } finally { + // Ensure we mark as ended even if the onEnd throws + isEnded = true; + } + } + }, + isRecording: () => isRecording && !isEnded, + recordException: (exception: OTelException, time?: OTelTimeInput) => { + if (!_handleIsEnded("recordException")) { + handleNotImplemented(errorHandlers, "Span.recordException: " + dumpObj(exception) + " not added"); + } + } + } as IReadableSpan, _toString()); + + // Make the relevant properties dynamic (and read-only) + objDefineProps(theSpan, { + name: { + g: () => spanName + }, + kind: { + v: kind || eOTelSpanKind.INTERNAL + }, + startTime: { + g: () => { + return spanStartTime.v; + } + }, + endTime: { + g: () => { + return spanEndTime; + } + }, + status: { + g: () => { + return spanStatus || { + code: eOTelSpanStatusCode.UNSET + }; + } + }, + attributes: { + g: () => { + return attributes ? attributes.v.attributes : objFreeze({}); + } + }, + attribContainer: { + g: () => { + if (!attributes && !localContainer) { + // Create an empty container and cache it for future use (for performance only) + localContainer = createAttributeContainer(otelCfg, spanName); + } + + return attributes ? attributes.v : localContainer; + } + }, + links: { + g: () => { + return []; + } + }, + events: { + g: () => { + return []; + } + }, + duration: { + g: () => { + return spanDuration || zeroHrTime(); + } + }, + ended: { + g: () => { + return isEnded; + } + }, + droppedAttributesCount: { + g: () => { + return attributes ? attributes.v.droppedAttributes : localDroppedAttributes; + } + }, + // droppedEventsCount: { + // g: () => { + // return droppedEvents; + // } + // }, + // droppedLinksCount: { + // g: () => { + // return droppedLinks; + // } + // }, + parentSpanContext: { + l: getDeferred(() => { + return spanCtx ? spanCtx.parentSpanContext : UNDEFINED_VALUE; + }) + }, + parentSpanId: { + l: getDeferred(() => { + let parentSpanId = UNDEFINED_VALUE; + if (spanCtx) { + let parentSpanCtx = spanCtx.parentSpanContext; + parentSpanId = parentSpanCtx ? parentSpanCtx.spanId : UNDEFINED_VALUE; + } + + return parentSpanId; + }) + } + }); + + return theSpan; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/spanContext.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/spanContext.ts deleted file mode 100644 index 0a9bf16ae..000000000 --- a/shared/AppInsightsCore/src/OpenTelemetry/trace/spanContext.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { isNullOrUndefined, isNumber, isObject, isString, objDefineProps } from "@nevware21/ts-utils"; -import { eW3CTraceFlags } from "../../JavaScriptSDK.Enums/W3CTraceFlags"; -import { IDistributedTraceContext } from "../../JavaScriptSDK.Interfaces/IDistributedTraceContext"; -import { INVALID_SPAN_ID, INVALID_TRACE_ID, isValidSpanId, isValidTraceId } from "../../JavaScriptSDK/W3cTraceParent"; -import { IOTelSpanContext } from "../interfaces/trace/IOTelSpanContext"; -import { IOTelTraceState } from "../interfaces/trace/IOTelTraceState"; -import { createOTelTraceState } from "./traceState"; - -export function createOTelSpanContext(traceContext: IDistributedTraceContext | IOTelSpanContext): IOTelSpanContext { - - let traceId = isValidTraceId(traceContext.traceId) ? traceContext.traceId : INVALID_TRACE_ID; - let spanId = isValidSpanId(traceContext.spanId) ? traceContext.spanId : INVALID_SPAN_ID; - let isRemote = traceContext.isRemote; - let traceFlags = (!isNullOrUndefined(traceContext.traceFlags) ? traceContext.traceFlags : eW3CTraceFlags.Sampled); - let otTraceState: IOTelTraceState | null = null; - - let traceContextObj: IOTelSpanContext = { - traceId, - spanId, - traceFlags - }; - - return objDefineProps(traceContextObj, { - traceId: { - g: () => traceId, - s: (value: string) => traceId = isValidTraceId(value) ? value : INVALID_TRACE_ID - }, - spanId: { - g: () => spanId, - s: (value: string) => spanId = isValidSpanId(value) ? value : INVALID_SPAN_ID - }, - isRemote: { - g: () => isRemote - }, - traceFlags: { - g: () => traceFlags, - s: (value: number) => traceFlags = value - }, - traceState: { - g: () => { - if (!otTraceState) { - // The Trace State has changed, update the local copy - otTraceState = createOTelTraceState(traceContext.traceState); - } - - return otTraceState; - }, - s: (value: IOTelTraceState) => { - // The Trace State has changed, update the local copy - otTraceState = value; - } - } - }); -} - -export function isSpanContext(spanContext: any): spanContext is IOTelSpanContext { - return spanContext && isObject(spanContext) && isString(spanContext.traceId) && isString(spanContext.spanId) && isNumber(spanContext.traceFlags); -} - -export function wrapDistributedTrace(traceContext: IDistributedTraceContext): IOTelSpanContext { - return createOTelSpanContext(traceContext); -} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/traceApi.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/traceApi.ts new file mode 100644 index 000000000..d33edfaec --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/traceApi.ts @@ -0,0 +1,43 @@ +import { ICachedValue, fnBind, getDeferred } from "@nevware21/ts-utils"; +import { IDistributedTraceContext } from "../../JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { setProtoTypeName } from "../../JavaScriptSDK/HelperFuncs"; +import { UNDEFINED_VALUE } from "../../JavaScriptSDK/InternalConstants"; +import { throwOTelError } from "../errors/OTelError"; +import { IOTelApi } from "../interfaces/IOTelApi"; +import { IOTelTracerProvider } from "../interfaces/trace/IOTelTracerProvider"; +import { IReadableSpan } from "../interfaces/trace/IReadableSpan"; +import { ITraceApi } from "../interfaces/trace/ITraceApi"; +import { isSpanContextValid, wrapSpanContext } from "./utils"; + +/** + * @internal + * Create a new instance of the OpenTelemetry Trace API, this is bound to the + * provided instance of the traceProvider (the {@link IOTelApi} instance), + * to "change" (setGlobalTraceProvider) you MUST create a new instance of this API. + * @param otelApi - The IOTelApi instance associated with this instance + * @returns A new instance of the ITraceApi for the provided ITelApi + */ +export function _createTraceApi(otelApi: IOTelApi): ICachedValue { + let traceProvider: IOTelTracerProvider = otelApi; + if (!traceProvider) { + throwOTelError("Must provide an otelApi instance"); + } + + return getDeferred(() => { + return setProtoTypeName({ + getTracer: fnBind(traceProvider.getTracer, traceProvider), + + // We use fnBind to automatically inject the "otelApi" argument as the first argument to the wrapSpanContext function + wrapSpanContext: fnBind(wrapSpanContext, UNDEFINED_VALUE, [otelApi]) as unknown as (spanContext: IDistributedTraceContext) => IReadableSpan, + + isSpanContextValid: isSpanContextValid, + + getActiveSpan: (): IReadableSpan | undefined | null => { + return otelApi.host ? otelApi.host.getActiveSpan() : null; + }, + setActiveSpan(span: IReadableSpan | undefined | null) { + return otelApi.host ? otelApi.host.setActiveSpan(span) : null; + } + }, "TraceApi"); + }); +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/traceProvider.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/traceProvider.ts new file mode 100644 index 000000000..31dd6ef87 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/traceProvider.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { objDefine, strSubstr } from "@nevware21/ts-utils"; +import { IDistributedTraceContext } from "../../JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { ITraceHost, ITraceProvider } from "../../JavaScriptSDK.Interfaces/ITraceProvider"; +import { generateW3CId } from "../../JavaScriptSDK/CoreUtils"; +import { createDistributedTraceContext } from "../../JavaScriptSDK/TelemetryHelpers"; +import { eOTelSpanKind } from "../enums/trace/OTelSpanKind"; +import { IOTelApi } from "../interfaces/IOTelApi"; +import { IOTelSpanCtx } from "../interfaces/trace/IOTelSpanCtx"; +import { IOTelSpanOptions } from "../interfaces/trace/IOTelSpanOptions"; +import { IReadableSpan } from "../interfaces/trace/IReadableSpan"; +import { createSpan } from "./span"; + +/** + * @internal + * Creates a new trace provider adapter + * @param host - The trace host instance (typically IAppInsightsCore). + * @param traceName - The name of the trace provider. + * @param api - The OpenTelemetry API instance (as a lazy value). + * @param onEnd - Optional callback to be invoked when a span ends. + * @returns The created trace provider. + */ +export function createTraceProvider(host: ITraceHost, traceName: string, api: IOTelApi, onEnd?: (span: IReadableSpan) => void): ITraceProvider { + let provider: ITraceProvider = { + api: null, + createSpan: (name: string, options?: IOTelSpanOptions, parent?: IDistributedTraceContext): IReadableSpan => { + let newCtx: IDistributedTraceContext; + let parentCtx: IDistributedTraceContext | undefined; + + if (options && options.root) { + newCtx = createDistributedTraceContext(); + } else { + newCtx = createDistributedTraceContext(parent || host.getTraceCtx()); + if (newCtx.parentCtx) { + parentCtx = newCtx.parentCtx; + } + } + + // Always generate a new spanId + newCtx.spanId = strSubstr(generateW3CId(), 0, 16); + + let spanCtx: IOTelSpanCtx = { + api: api, + spanContext: newCtx, + attributes: options ? options.attributes : undefined, + startTime: options ? options.startTime : undefined, + isRecording: options ? options.recording !== false : true, + onEnd: onEnd + }; + + if (parentCtx) { + objDefine(spanCtx, "parentSpanContext", { + v: parentCtx, + w: false + }); + } + + return createSpan(spanCtx, name, options?.kind || eOTelSpanKind.INTERNAL); + }, + getProviderId: () => traceName, + isAvailable: () => !!onEnd + }; + + objDefine(provider, "api", { + v: api, + w: false + }); + + return provider; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/tracer.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/tracer.ts new file mode 100644 index 000000000..a998549b5 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/tracer.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { fnApply, isFunction } from "@nevware21/ts-utils"; +import { ITraceHost } from "../../JavaScriptSDK.Interfaces/ITraceProvider"; +import { setProtoTypeName } from "../../JavaScriptSDK/HelperFuncs"; +import { STR_EMPTY } from "../../JavaScriptSDK/InternalConstants"; +import { IOTelSpanOptions } from "../interfaces/trace/IOTelSpanOptions"; +import { IOTelTracer } from "../interfaces/trace/IOTelTracer"; +import { IReadableSpan } from "../interfaces/trace/IReadableSpan"; + +/** + * @internal + * Create a tracer implementation. + * @param host - The ApplicationInsights core instance + * @returns A tracer object + */ +export function _createTracer(host: ITraceHost, name?: string): IOTelTracer { + let tracer: IOTelTracer = setProtoTypeName({ + startSpan(spanName: string, options?: IOTelSpanOptions): IReadableSpan | null { + // Note: context is not used / needed for Application Insights / 1DS + if (host) { + return host.startSpan(spanName, options); + } + + return null; + }, + startActiveSpan unknown>(name: string, fnOrOptions?: F | IOTelSpanOptions, fn?: F): ReturnType { + // Figure out which parameter order was passed + let theFn: F | null = null; + let opts: IOTelSpanOptions | null = null; + + if (isFunction(fnOrOptions)) { + // startActiveSpan unknown>(name: string, fn: F): ReturnType; + theFn = fnOrOptions; + } else { + // startActiveSpan unknown>(name: string, options: IOTelSpanOptions, fn: F): ReturnType; or + opts = fnOrOptions as IOTelSpanOptions; + theFn = fn; + } + + if (theFn) { + const span = tracer.startSpan(name, opts); + if (span) { + try { + return fnApply(theFn, [span]); + } finally { + span.end(); + } + } + } + + return; + } + }, "OTelTracer" + (name ? (" (" + name + ")") : STR_EMPTY)); + + return tracer; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/tracerProvider.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/tracerProvider.ts new file mode 100644 index 000000000..9e9984fd4 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/tracerProvider.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IPromise } from "@nevware21/ts-async"; +import { ITraceHost } from "../../JavaScriptSDK.Interfaces/ITraceProvider"; +import { IOTelTracer } from "../interfaces/trace/IOTelTracer"; +import { IOTelTracerProvider } from "../interfaces/trace/IOTelTracerProvider"; +import { _createTracer } from "./tracer"; + +/** + * @internal + * Create a trace implementation with tracer caching. + * @param core - The ApplicationInsights core instance + * @returns A trace object + */ +export function _createTracerProvider(host: ITraceHost): IOTelTracerProvider { + let tracers: { [key: string]: IOTelTracer } = {}; + + return { + getTracer(name: string, version?: string): IOTelTracer { + const tracerKey = (name|| "ai-web") + "@" + (version || "unknown"); + + if (!tracers[tracerKey]) { + tracers[tracerKey] = _createTracer(host); + } + + return tracers[tracerKey]; + }, + forceFlush(): IPromise | void { + // Nothing to flush + return; + }, + shutdown(): IPromise | void { + // Just clear the locally cached IOTelTracer instances so they can be garbage collected + tracers = {}; + host = null; + return; + } + }; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/utils.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/utils.ts new file mode 100644 index 000000000..fd783b899 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/utils.ts @@ -0,0 +1,252 @@ +import { doFinally } from "@nevware21/ts-async"; +import { arrSlice, fnApply, isFunction, isObject, isPromiseLike } from "@nevware21/ts-utils"; +import { IAppInsightsCore } from "../../JavaScriptSDK.Interfaces/IAppInsightsCore"; +import { IConfiguration } from "../../JavaScriptSDK.Interfaces/IConfiguration"; +import { IDistributedTraceContext, IDistributedTraceInit } from "../../JavaScriptSDK.Interfaces/IDistributedTraceContext"; +import { ISpanScope, ITraceHost } from "../../JavaScriptSDK.Interfaces/ITraceProvider"; +import { createDistributedTraceContext, isDistributedTraceContext } from "../../JavaScriptSDK/TelemetryHelpers"; +import { isValidSpanId, isValidTraceId } from "../../JavaScriptSDK/W3cTraceParent"; +import { eOTelSpanKind } from "../enums/trace/OTelSpanKind"; +import { IOTelApi } from "../interfaces/IOTelApi"; +import { ITraceCfg } from "../interfaces/config/ITraceCfg"; +import { IOTelSpanCtx } from "../interfaces/trace/IOTelSpanCtx"; +import { IReadableSpan } from "../interfaces/trace/IReadableSpan"; +import { createSpan } from "./span"; + +/** + * Internal helper to execute a callback function with a span set as the active span. + * Handles both synchronous and asynchronous (Promise-based) callbacks, ensuring + * the previous active span is properly restored after execution. + * @param scope - The span scope instance + * @param fn - The callback function to execute + * @param thisArg - The `this` context for the callback + * @param args - Array of arguments to pass to the callback + * @returns The result of the callback function + */ +function _executeWithActiveSpan( + scope: S, + fn: (...args: any) => any, + thisArg: any, + args: any[] +): R { + let isAsync = false; + try { + let result = fnApply(fn, thisArg || scope, args); + if (isPromiseLike(result)) { + isAsync = true; + return doFinally(result, function () { + // Restore previous active span after promise settles (resolves or rejects) + if (scope) { + scope.restore(); + } + }) as any; + } + return result; + } finally { + // Restore previous active span only if result is not a promise + // (promises handle restoration in their callbacks) + if (scope && !isAsync) { + scope.restore(); + } + } +} + +/** + * Execute the callback `fn` function with the passed span as the active span + * @param traceHost - The current trace host instance (core or AISKU instance) + * @param span - The span to set as the active span during the execution of the callback + * @param fn - the callback function + * @param thisArg - the `this` argument for the callback. If not provided, ISpanScope is used as `this` + * @param _args - Additional arguments to be passed to the function + */ +export function withSpan | ISpanScope, ...args: A) => ReturnType>(traceHost: T, span: IReadableSpan, fn: F, thisArg?: ThisParameterType, ..._args: A) : ReturnType; + +/** + * Execute the callback `fn` function with the passed span as the active span + * @param traceHost - The current trace host instance (core or AISKU instance) + * @param span - The span to set as the active span during the execution of the callback + * @param fn - the callback function + * @param thisArg - the `this` argument for the callback. If not provided, ISpanScope is used as `this` + * @returns the result of the function + */ +export function withSpan | ISpanScope,...args: A) => ReturnType>(traceHost: T, span: IReadableSpan, fn: F, thisArg?: ThisParameterType): ReturnType { + const scope = traceHost.setActiveSpan(span); + return _executeWithActiveSpan(scope, fn, thisArg, arrSlice(arguments, 4)); +} + +/** + * Execute the callback `fn` function with the passed span as the active span. The callback receives + * an ISpanScope object as its first parameter and the `this` context (when no thisArg is provided). + * @param traceHost - The current trace host instance (core or AISKU instance) + * @param span - The span to set as the active span during the execution of the callback + * @param fn - the callback function that receives an ISpanScope + * @param thisArg - the `this` argument for the callback. If not provided, ISpanScope is used as `this` + * @returns The result of the function + */ +export function useSpan | ISpanScope, scope: ISpanScope) => ReturnType>(traceHost: T, span: IReadableSpan, fn: F, thisArg?: ThisParameterType) : ReturnType; + +/** + * Execute the callback `fn` function with the passed span as the active span. The callback receives + * an ISpanScope object as its first parameter and the `this` context (when no thisArg is provided). + * @param traceHost - The current trace host instance (core or AISKU instance) + * @param span - The span to set as the active span during the execution of the callback + * @param fn - the callback function that receives an ISpanScope and additional arguments + * @param thisArg - the `this` argument for the callback. If not provided, ISpanScope is used as `this` + * @param _args - Additional arguments to be passed to the function + * @returns The result of the function + */ +export function useSpan | ISpanScope, scope: ISpanScope, ...args: A) => ReturnType>(traceHost: T, span: IReadableSpan, fn: F, thisArg?: ThisParameterType, ..._args: A) : ReturnType; + +/** + * Execute the callback `fn` function with the passed span as the active span. The callback receives + * an ISpanScope object as its first parameter and the `this` context (when no thisArg is provided). + * @param traceHost - The current trace host instance (core or AISKU instance) + * @param span - The span to set as the active span during the execution of the callback + * @param fn - the callback function that receives an ISpanScope and additional arguments + * @param thisArg - the `this` argument for the callback. If not provided, ISpanScope is used as `this` + * @param _args - Additional arguments to be passed to the function + */ +export function useSpan | ISpanScope, scope: ISpanScope, ...args: A) => ReturnType>(traceHost: T, span: IReadableSpan, fn: F, thisArg?: ThisParameterType): ReturnType { + let scope = traceHost.setActiveSpan(span); + return _executeWithActiveSpan(scope, fn, thisArg, [scope].concat(arrSlice(arguments, 4))); +} + +/** + * Returns true if the passed spanContext of type {@link IDistributedTraceContext} or {@link IDistributedTraceInit} is valid. + * @return true if this {@link IDistributedTraceContext} is valid. + */ +/*#__NO_SIDE_EFFECTS__*/ +export function isSpanContextValid(spanContext: IDistributedTraceContext | IDistributedTraceInit): boolean { + return spanContext ? (isValidTraceId(spanContext.traceId) && isValidSpanId(spanContext.spanId)) : false; +} + +/** + * Wrap the given {@link IDistributedTraceContext} in a new non-recording {@link IReadableSpan} + * + * @param spanContext - span context to be wrapped + * @returns a new non-recording {@link IReadableSpan} with the provided context + */ +export function wrapSpanContext(otelApi: IOTelApi, spanContext: IDistributedTraceContext | IDistributedTraceInit): IReadableSpan { + if (!isDistributedTraceContext(spanContext)) { + spanContext = createDistributedTraceContext(spanContext); + } + + // Return a non-recording span + return createNonRecordingSpan(otelApi, "wrapped(\"" + spanContext.spanId + "\")", spanContext); +} + +/** + * Return a non-recording span based on the provided spanContext using the otelApi instance as the + * owning instance. + * @param otelApi - The otelApi to use for creating the non-Recording Span + * @param spanName - The span name to associated with the span + * @param spanContext - The Span context to use for the span + * @returns A new span that is marked as a non-recording span + */ +export function createNonRecordingSpan(otelApi: IOTelApi, spanName: string, spanContext: IDistributedTraceContext): IReadableSpan { + // Return a non-recording span + let spanCtx: IOTelSpanCtx = { + api: otelApi, + spanContext: spanContext, + isRecording: false + }; + + return createSpan(spanCtx, spanName, eOTelSpanKind.INTERNAL); +} + +/** + * Identifies whether the span is an {@link IReadableSpan} or not + * @param span - The span to check + * @returns true if the span is an {@link IReadableSpan} otherwise false + */ +/*#__NO_SIDE_EFFECTS__*/ +export function isReadableSpan(span: any): span is IReadableSpan { + return !!span && + isObject(span) && + "name" in span && + "kind" in span && + isFunction(span.spanContext) && + "duration" in span && + "ended" in span && + "startTime" in span && + "endTime" in span && + "attributes" in span && + "links" in span && + "events" in span && + "status" in span && + // "resource" in span && + // "instrumentationScope" in span && + "droppedAttributesCount" in span && + isFunction(span.isRecording) && + isFunction(span.setStatus) && + isFunction(span.updateName) && + isFunction(span.setAttribute) && + isFunction(span.setAttributes) && + isFunction(span.end) && + isFunction(span.recordException); +} + +function _getTraceCfg(context: IOTelApi | ITraceHost | IConfiguration): ITraceCfg { + let traceCfg: ITraceCfg = null; + if (context) { + if ((context as IOTelApi).cfg && (context as IOTelApi).host) { + traceCfg = (context as IOTelApi).cfg.traceCfg; + } else if (isFunction((context as IAppInsightsCore).initialize) && (context as IAppInsightsCore).config) { + traceCfg = (context as IAppInsightsCore).config.traceCfg; + } else if ((context as IConfiguration).traceCfg) { + traceCfg = (context as IConfiguration).traceCfg; + } + } + + return traceCfg; +} + +/** + * Set the suppress tracing flag on the context + * @param context - The context to set the suppress tracing flag on + * @returns The context with the suppress tracing flag set + * @remarks This is used to suppress tracing for the current context and all child contexts + */ +/*#__NO_SIDE_EFFECTS__*/ +export function suppressTracing(context: T): T { + let traceCfg = _getTraceCfg(context); + if (traceCfg) { + traceCfg.suppressTracing = true; + } + + return context; +} + +/** + * Remove the suppress tracing flag from the context + * @param context - The context to remove the suppress tracing flag from + * @returns The context with the suppress tracing flag removed + * @remarks This is used to remove the suppress tracing flag from the current context and all child + * contexts. This is used to restore tracing after it has been suppressed + */ +/*#__NO_SIDE_EFFECTS__*/ +export function unsuppressTracing(context: T): T { + let traceCfg = _getTraceCfg(context); + if (traceCfg) { + traceCfg.suppressTracing = false; + } + + return context; +} + +/** + * Check if the tracing is suppressed for the current context + * @param context - The context to check for the suppress tracing flag + * @returns true if tracing is suppressed for the current context otherwise false + * @remarks This is used to check if tracing is suppressed for the current context and all child + */ +/*#__NO_SIDE_EFFECTS__*/ +export function isTracingSuppressed(context: T): boolean { + let result = false; + let traceCfg = _getTraceCfg(context); + if (traceCfg) { + result = !!traceCfg.suppressTracing; + } + + return result; +} diff --git a/shared/AppInsightsCore/src/applicationinsights-core-js.ts b/shared/AppInsightsCore/src/applicationinsights-core-js.ts index 2d4a13df9..af32f89db 100644 --- a/shared/AppInsightsCore/src/applicationinsights-core-js.ts +++ b/shared/AppInsightsCore/src/applicationinsights-core-js.ts @@ -16,6 +16,7 @@ export { IUnloadableComponent } from "./JavaScriptSDK.Interfaces/IUnloadableComp export { IPayloadData, SendPOSTFunction, IXHROverride, OnCompleteCallback } from "./JavaScriptSDK.Interfaces/IXHROverride" export { IUnloadHook, ILegacyUnloadHook } from "./JavaScriptSDK.Interfaces/IUnloadHook"; export { eEventsDiscardedReason, EventsDiscardedReason, eBatchDiscardedReason, BatchDiscardedReason } from "./JavaScriptSDK.Enums/EventsDiscardedReason"; +export { eDependencyTypes, DependencyTypes } from "./JavaScriptSDK.Enums/DependencyTypes"; export { SendRequestReason, TransportType } from "./JavaScriptSDK.Enums/SendRequestReason"; //export { StatsType, eStatsType } from "./JavaScriptSDK.Enums/StatsType"; export { TelemetryUpdateReason } from "./JavaScriptSDK.Enums/TelemetryUpdateReason"; @@ -31,7 +32,7 @@ export { normalizeJsName, toISOString, getExceptionName, strContains, setValue, getSetValue, proxyAssign, proxyFunctions, proxyFunctionAs, createClassFromInterface, optimizeObject, isNotUndefined, isNotNullOrUndefined, objExtend, isFeatureEnabled, getResponseText, formatErrorMessageXdr, formatErrorMessageXhr, prependTransports, - openXhr, _appendHeader, _getAllResponseHeaders, convertAllHeadersToMap + openXhr, _appendHeader, _getAllResponseHeaders, setObjStringTag, setProtoTypeName } from "./JavaScriptSDK/HelperFuncs"; export { parseResponse } from "./JavaScriptSDK/ResponseHelpers"; export { IXDomainRequest, IBackendResponse } from "./JavaScriptSDK.Interfaces/IXDomainRequest"; @@ -42,12 +43,13 @@ export { SenderPostManager } from "./JavaScriptSDK/SenderPostManager"; //export { IStatsMgr, IStatsMgrConfig } from "./JavaScriptSDK.Interfaces/IStatsMgr"; //export { createStatsMgr } from "./JavaScriptSDK/StatsBeat"; export { - isArray, isTypeof, isUndefined, isNullOrUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, + isArray, isTypeof, isUndefined, isNullOrUndefined, isStrictUndefined, objHasOwnProperty as hasOwnProperty, isObject, isFunction, strEndsWith, strStartsWith, isDate, isError, isString, isNumber, isBoolean, arrForEach, arrIndexOf, - arrReduce, arrMap, strTrim, objKeys, objDefineAccessors, throwError, isSymbol, + arrReduce, arrMap, strTrim, objKeys, objCreate, objDefine, objDefineProp, objDefineAccessors, throwError, isSymbol, isNotTruthy, isTruthy, objFreeze, objSeal, objToString, objDeepFreeze as deepFreeze, getInst as getGlobalInst, hasWindow, getWindow, hasDocument, getDocument, hasNavigator, getNavigator, hasHistory, - getHistory, dumpObj, asString, objForEachKey, getPerformance, utcNow as dateNow, perfNow + getHistory, dumpObj, asString, objForEachKey, getPerformance, utcNow as dateNow, perfNow, + ObjDefinePropDescriptor } from "@nevware21/ts-utils"; export { EnumValue, createEnumStyle, createValueMap } from "./JavaScriptSDK.Enums/EnumHelperFuncs"; export { @@ -78,10 +80,10 @@ export { IFeatureOptInDetails, IFeatureOptIn } from "./JavaScriptSDK.Interfaces/ export { FeatureOptInMode, CdnFeatureMode } from "./JavaScriptSDK.Enums/FeatureOptInEnums" export { safeGetLogger, DiagnosticLogger, _InternalLogMessage, _throwInternal, _warnToConsole, _logInternalMessage } from "./JavaScriptSDK/DiagnosticLogger"; export { - ProcessTelemetryContext, createProcessTelemetryContext + createProcessTelemetryContext // Explicitly NOT exporting createProcessTelemetryUnloadContext() and createProcessTelemetryUpdateContext() as these should only be created internally } from "./JavaScriptSDK/ProcessTelemetryContext"; -export { initializePlugins, sortPlugins, unloadComponents, createDistributedTraceContext } from "./JavaScriptSDK/TelemetryHelpers"; +export { initializePlugins, sortPlugins, unloadComponents, createDistributedTraceContext, isDistributedTraceContext } from "./JavaScriptSDK/TelemetryHelpers"; export { _eInternalMessageId, _InternalMessageId, LoggingSeverity, eLoggingSeverity } from "./JavaScriptSDK.Enums/LoggingEnums"; export { InstrumentProto, InstrumentProtos, InstrumentFunc, InstrumentFuncs, InstrumentEvent } from "./JavaScriptSDK/InstrumentHooks"; export { ICookieMgr, ICookieMgrConfig } from "./JavaScriptSDK.Interfaces/ICookieMgr"; @@ -96,7 +98,7 @@ export { UnloadHandler, IUnloadHandlerContainer, createUnloadHandlerContainer } export { IUnloadHookContainer, createUnloadHookContainer, _testHookMaxUnloadHooksCb } from "./JavaScriptSDK/UnloadHookContainer"; export { ITelemetryUpdateState } from "./JavaScriptSDK.Interfaces/ITelemetryUpdateState"; export { ITelemetryUnloadState } from "./JavaScriptSDK.Interfaces/ITelemetryUnloadState"; -export { IDistributedTraceContext } from "./JavaScriptSDK.Interfaces/IDistributedTraceContext"; +export { IDistributedTraceContext, IDistributedTraceInit } from "./JavaScriptSDK.Interfaces/IDistributedTraceContext"; export { ITraceParent } from "./JavaScriptSDK.Interfaces/ITraceParent"; export { createTraceParent, parseTraceParent, isValidTraceId, isValidSpanId, isValidTraceParent, isSampledFlag, formatTraceParent, findW3cTraceParent, @@ -117,15 +119,89 @@ export { eW3CTraceFlags } from "./JavaScriptSDK.Enums/W3CTraceFlags"; export { IW3cTraceState } from "./JavaScriptSDK.Interfaces/IW3cTraceState"; export { createW3cTraceState, findW3cTraceState, isW3cTraceState, snapshotW3cTraceState } from "./JavaScriptSDK/W3cTraceState"; -// ========================================================================== -// OpenTelemetry exports -// ========================================================================== +// OpenTelemetry Trace support +export { IOTelTraceState } from "./OpenTelemetry/interfaces/trace/IOTelTraceState"; +export { IOTelSpan } from "./OpenTelemetry/interfaces/trace/IOTelSpan"; +export { IOTelTracer } from "./OpenTelemetry/interfaces/trace/IOTelTracer"; +export { IOTelTracerProvider, IOTelTracerOptions } from "./OpenTelemetry/interfaces/trace/IOTelTracerProvider"; +export { ITraceProvider, ITraceHost, ISpanScope } from "./JavaScriptSDK.Interfaces/ITraceProvider"; +export { IOTelSpanOptions } from "./OpenTelemetry/interfaces/trace/IOTelSpanOptions"; +export { createOTelTraceState } from "./OpenTelemetry/trace/traceState"; +export { createSpan } from "./OpenTelemetry/trace/span"; +export { createTraceProvider } from "./OpenTelemetry/trace/traceProvider"; +export { isSpanContextValid, wrapSpanContext, isReadableSpan, suppressTracing, unsuppressTracing, isTracingSuppressed, useSpan, withSpan } from "./OpenTelemetry/trace/utils"; +export { + AzureMonitorSampleRate, ApplicationInsightsCustomEventName, MicrosoftClientIp, ApplicationInsightsMessageName, + ApplicationInsightsExceptionName, ApplicationInsightsPageViewName, ApplicationInsightsAvailabilityName, + ApplicationInsightsEventName, ApplicationInsightsBaseType, ApplicationInsightsMessageBaseType, + ApplicationInsightsExceptionBaseType, ApplicationInsightsPageViewBaseType, ApplicationInsightsAvailabilityBaseType, + ApplicationInsightsEventBaseType, ATTR_ENDUSER_ID, ATTR_ENDUSER_PSEUDO_ID, ATTR_HTTP_ROUTE, SEMATTRS_NET_PEER_IP, + SEMATTRS_NET_PEER_NAME, SEMATTRS_NET_HOST_IP, SEMATTRS_PEER_SERVICE, SEMATTRS_HTTP_USER_AGENT, SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_URL, SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_HTTP_ROUTE, SEMATTRS_HTTP_HOST, SEMATTRS_DB_SYSTEM, + SEMATTRS_DB_STATEMENT, SEMATTRS_DB_OPERATION, SEMATTRS_DB_NAME, SEMATTRS_RPC_SYSTEM, SEMATTRS_RPC_GRPC_STATUS_CODE, + SEMATTRS_EXCEPTION_TYPE, SEMATTRS_EXCEPTION_MESSAGE, SEMATTRS_EXCEPTION_STACKTRACE, SEMATTRS_HTTP_SCHEME, + SEMATTRS_HTTP_TARGET, SEMATTRS_HTTP_FLAVOR, SEMATTRS_NET_TRANSPORT, SEMATTRS_NET_HOST_NAME, SEMATTRS_NET_HOST_PORT, + SEMATTRS_NET_PEER_PORT, SEMATTRS_HTTP_CLIENT_IP, SEMATTRS_ENDUSER_ID, ATTR_CLIENT_ADDRESS, ATTR_CLIENT_PORT, + ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, ATTR_URL_PATH, ATTR_URL_QUERY, ATTR_URL_SCHEME, + ATTR_ERROR_TYPE, ATTR_NETWORK_LOCAL_ADDRESS, ATTR_NETWORK_LOCAL_PORT, ATTR_NETWORK_PROTOCOL_NAME, + ATTR_NETWORK_PEER_ADDRESS, ATTR_NETWORK_PEER_PORT, ATTR_NETWORK_PROTOCOL_VERSION, ATTR_NETWORK_TRANSPORT, + ATTR_USER_AGENT_ORIGINAL, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_EXCEPTION_TYPE, + ATTR_EXCEPTION_MESSAGE, ATTR_EXCEPTION_STACKTRACE, EXP_ATTR_ENDUSER_ID, EXP_ATTR_ENDUSER_PSEUDO_ID, + EXP_ATTR_SYNTHETIC_TYPE, DBSYSTEMVALUES_MONGODB, DBSYSTEMVALUES_COSMOSDB, DBSYSTEMVALUES_MYSQL, + DBSYSTEMVALUES_POSTGRESQL, DBSYSTEMVALUES_REDIS, DBSYSTEMVALUES_DB2, DBSYSTEMVALUES_DERBY, DBSYSTEMVALUES_MARIADB, + DBSYSTEMVALUES_MSSQL, DBSYSTEMVALUES_ORACLE, DBSYSTEMVALUES_SQLITE, DBSYSTEMVALUES_OTHER_SQL, DBSYSTEMVALUES_HSQLDB, + DBSYSTEMVALUES_H2 +} from "./OpenTelemetry/attribute/SemanticConventions" -// --------------------------------------------------------------------------- -// Interfaces -// --------------------------------------------------------------------------- +// OpenTelemetry Core API Interfaces +export { IOTelApi } from "./OpenTelemetry/interfaces/IOTelApi"; +export { IOTelApiCtx } from "./OpenTelemetry/interfaces/IOTelApiCtx"; +export { IOTelAttributes, OTelAttributeValue, ExtendedOTelAttributeValue } from "./OpenTelemetry/interfaces/IOTelAttributes"; +export { OTelException } from "./OpenTelemetry/interfaces/IOTelException"; +export { IOTelHrTime, OTelTimeInput } from "./OpenTelemetry/interfaces/IOTelHrTime"; +export { createOTelApi } from "./OpenTelemetry/otelApi"; -// Trace -export { IOTelTraceState } from "./OpenTelemetry/interfaces/trace/IOTelTraceState"; -export { IOTelSpanContext } from "./OpenTelemetry/interfaces/trace/IOTelSpanContext"; +// OpenTelemetry Trace Interfaces +export { ITraceApi } from "./OpenTelemetry/interfaces/trace/ITraceApi"; +export { IOTelSpanCtx } from "./OpenTelemetry/interfaces/trace/IOTelSpanCtx"; +export { IOTelSpanStatus } from "./OpenTelemetry/interfaces/trace/IOTelSpanStatus"; +export { IReadableSpan } from "./OpenTelemetry/interfaces/trace/IReadableSpan"; + +// OpenTelemetry Configuration Interfaces +export { IOTelConfig } from "./OpenTelemetry/interfaces/config/IOTelConfig"; +export { IOTelAttributeLimits } from "./OpenTelemetry/interfaces/config/IOTelAttributeLimits"; +export { IOTelErrorHandlers } from "./OpenTelemetry/interfaces/config/IOTelErrorHandlers"; +export { ITraceCfg } from "./OpenTelemetry/interfaces/config/ITraceCfg"; + +// OpenTelemetry Attribute Support +export { IAttributeContainer, IAttributeChangeInfo } from "./OpenTelemetry/attribute/IAttributeContainer"; +export { eAttributeChangeOp, AttributeChangeOp } from "./OpenTelemetry/enums/eAttributeChangeOp"; +export { createAttributeContainer, addAttributes, isAttributeContainer, createAttributeSnapshot } from "./OpenTelemetry/attribute/attributeContainer"; +export { eAttributeFilter, AttributeFilter } from "./OpenTelemetry/attribute/IAttributeContainer"; + +// OpenTelemetry Enums +export { eOTelSpanKind, OTelSpanKind } from "./OpenTelemetry/enums/trace/OTelSpanKind"; +export { eOTelSpanStatusCode, OTelSpanStatusCode } from "./OpenTelemetry/enums/trace/OTelSpanStatus"; + +// OpenTelemetry Helper Utilities +export { + hrTime, hrTimeToTimeStamp, hrTimeDuration, hrTimeToMilliseconds, timeInputToHrTime, millisToHrTime, hrTimeToNanoseconds, + addHrTimes, hrTimeToMicroseconds, zeroHrTime, nanosToHrTime, isTimeInput, isTimeInputHrTime, isTimeSpan +} from "./OpenTelemetry/helpers/timeHelpers"; +export { isAttributeValue, isAttributeKey, sanitizeAttributes } from "./OpenTelemetry/helpers/attributeHelpers"; +export { + isSyntheticSource, serializeAttribute, getUrl, getPeerIp, getHttpMethod, getHttpUrl, getHttpHost, getHttpScheme, + getHttpTarget, getNetPeerName, getNetPeerPort, getUserAgent, getLocationIp, getHttpStatusCode, getHttpClientIp, + getDependencyTarget, isSqlDB +} from "./OpenTelemetry/helpers/common"; + +// OpenTelemetry Error Handlers +export { + handleAttribError, handleSpanError, handleDebug, handleWarn, handleError, handleNotImplemented +} from "./OpenTelemetry/helpers/handleErrors"; + +// OpenTelemetry Error Classes +export { OpenTelemetryError, OpenTelemetryErrorConstructor, getOpenTelemetryError, throwOTelError } from "./OpenTelemetry/errors/OTelError"; +export { OTelInvalidAttributeError, throwOTelInvalidAttributeError } from "./OpenTelemetry/errors/OTelInvalidAttributeError"; +export { OTelSpanError, throwOTelSpanError } from "./OpenTelemetry/errors/OTelSpanError"; diff --git a/tools/grunt-tasks/minifyNames.js b/tools/grunt-tasks/minifyNames.js index 021f6279c..2e8cd6104 100644 --- a/tools/grunt-tasks/minifyNames.js +++ b/tools/grunt-tasks/minifyNames.js @@ -29,7 +29,7 @@ var defaultInternalConstants = [ var defaultIgnoreConst = [ ]; var defaultIgnoreNames = [ - "_e[A-Z]*", "e[A-Z]*", "[A-Z]*Type", "[A-Z]*Value", "[A-Z]*Reason", "chrome.*" + "_e[A-Z]*", "e[A-Z]*", "[A-Z]*Type", "[A-Z]*Value", "[A-Z]*Reason", "chrome.*", "WellKnownSymbols" ]; function _createEnumWildcardRegEx(value) { diff --git a/tools/sizeImageGenerator/size-image-generator.js b/tools/sizeImageGenerator/size-image-generator.js index 3e3b0b334..6de8cb1e6 100644 --- a/tools/sizeImageGenerator/size-image-generator.js +++ b/tools/sizeImageGenerator/size-image-generator.js @@ -4,7 +4,7 @@ const fs = require('fs'); const request = require('request'); // const zlib = require('zlib'); -async function generateSizeBadge(path, fileSize, isGzip = false, maxSize = 35, minSize = 30) { +async function generateSizeBadge(path, fileSize, isGzip = false, maxSize = 80, minSize = 70) { try { let sizeBadge = `https://img.shields.io/badge/size-${fileSize}kb`; if (isGzip) { diff --git a/tsdoc.json b/tsdoc.json index 47181a276..04892f4ca 100644 --- a/tsdoc.json +++ b/tsdoc.json @@ -21,6 +21,14 @@ "tagName": "@default", "syntaxKind": "block" }, + { + "tagName": "@defaultBehavior", + "syntaxKind": "block" + }, + { + "tagName": "@function", + "syntaxKind": "block" + }, { "tagName": "@property", "syntaxKind": "block" @@ -57,6 +65,8 @@ "@since": true, "@returns": true, "@default": true, + "@defaultBehavior": true, + "@function": true, "@property": true, "@type": true, "@export": true,