From bc40075438608a1be94d3250570daa4c88094092 Mon Sep 17 00:00:00 2001 From: Nev Wylie <54870357+MSNev@users.noreply.github.com> Date: Fri, 11 Jul 2025 16:55:24 -0700 Subject: [PATCH 1/2] [Beta] Add W3c Trace State support / handling and refactor distributed trace handling to prepare for OptenTelemetry Span style API / management --- .aiAutoMinify.json | 5 +- .github/copilot-instructions.md | 119 +- AISKU/Tests/Unit/src/AISKUSize.Tests.ts | 8 +- .../Unit/src/SnippetInitialization.Tests.ts | 13 +- .../Unit/src/applicationinsights.e2e.tests.ts | 13 +- AISKU/src/AISku.ts | 2 +- .../Tests/Unit/src/AISKULightSize.Tests.ts | 8 +- RELEASES.md | 62 + common/config/rush/npm-shrinkwrap.json | 270 +-- .../src/dependencies-example-index.ts | 4 +- .../src/JavaScriptSDK/AnalyticsPlugin.ts | 60 +- .../Telemetry/PageVisitTimeManager.ts | 40 +- .../Tests/Unit/src/cfgsynchelper.tests.ts | 3 +- .../Tests/Unit/src/TestChannelPlugin.ts | 54 + .../Unit/src/W3CTraceStateDependency.tests.ts | 989 ++++++++++ .../Tests/Unit/src/ajax.tests.ts | 352 +++- .../Tests/Unit/src/dependencies.tests.ts | 2 + .../src/DependencyListener.ts | 9 +- .../src/InternalConstants.ts | 1 + .../src/ajax.ts | 417 ++++- .../src/ajaxRecord.ts | 404 ++--- .../src/ajaxUtils.ts | 23 - .../applicationinsights-dependencies-js.ts | 3 +- .../Tests/Unit/src/TelemetryContext.Tests.ts | 9 +- .../Tests/Unit/src/properties.tests.ts | 29 +- .../src/Context/TelemetryTrace.ts | 29 - .../src/PropertiesPlugin.ts | 28 +- .../src/TelemetryContext.ts | 145 +- .../src/applicationinsights-properties-js.ts | 5 +- .../test/Unit/src/FileSizeCheckTest.ts | 4 +- .../Unit/src/W3CTraceStateModes.tests.ts | 224 +++ .../Unit/src/appinsights-common.tests.ts | 2 + shared/AppInsightsCommon/src/Enums.ts | 84 +- .../src/Interfaces/Context/ITelemetryTrace.ts | 14 +- .../src/Interfaces/ICorrelationConfig.ts | 94 +- .../src/Interfaces/ITelemetryContext.ts | 2 + .../src/RequestResponseHeaders.ts | 8 +- shared/AppInsightsCommon/src/Util.ts | 49 +- .../src/applicationinsights-common.ts | 2 +- .../Unit/src/AppInsightsCoreSize.Tests.ts | 8 +- .../src/OpenTelemetry/traceState.Tests.ts | 448 +++++ .../Tests/Unit/src/W3TraceState.Tests.ts | 1606 +++++++++++++++++ .../Tests/Unit/src/aiunittests.ts | 4 + .../src/Config/ConfigDefaultHelpers.ts | 2 +- .../JavaScriptSDK.Enums/TraceHeadersMode.ts | 29 + .../src/JavaScriptSDK.Enums/W3CTraceFlags.ts | 18 + .../IAppInsightsCore.ts | 2 +- .../IConfiguration.ts | 11 +- .../IDistributedTraceContext.ts | 99 +- .../IProcessTelemetryContext.ts | 7 +- .../IW3cTraceState.ts | 78 + .../src/JavaScriptSDK/AppInsightsCore.ts | 63 +- .../src/JavaScriptSDK/EnvUtils.ts | 69 +- .../JavaScriptSDK/ProcessTelemetryContext.ts | 47 +- .../src/JavaScriptSDK/TelemetryHelpers.ts | 220 ++- .../src/JavaScriptSDK/W3cTraceParent.ts | 14 +- .../src/JavaScriptSDK/W3cTraceState.ts | 453 +++++ .../interfaces/trace/IOTelSpanContext.ts | 57 + .../interfaces/trace/IOTelTraceState.ts | 62 + .../src/OpenTelemetry/trace/spanContext.ts | 65 + .../src/OpenTelemetry/trace/traceState.ts | 103 ++ .../src/applicationinsights-core-js.ts | 25 +- tools/rollup-es5/src/ImportCheck.ts | 5 +- 63 files changed, 6215 insertions(+), 869 deletions(-) create mode 100644 extensions/applicationinsights-dependencies-js/Tests/Unit/src/TestChannelPlugin.ts create mode 100644 extensions/applicationinsights-dependencies-js/Tests/Unit/src/W3CTraceStateDependency.tests.ts delete mode 100644 extensions/applicationinsights-dependencies-js/src/ajaxUtils.ts delete mode 100644 extensions/applicationinsights-properties-js/src/Context/TelemetryTrace.ts create mode 100644 shared/AppInsightsCommon/Tests/Unit/src/W3CTraceStateModes.tests.ts create mode 100644 shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/traceState.Tests.ts create mode 100644 shared/AppInsightsCore/Tests/Unit/src/W3TraceState.Tests.ts create mode 100644 shared/AppInsightsCore/src/JavaScriptSDK.Enums/TraceHeadersMode.ts create mode 100644 shared/AppInsightsCore/src/JavaScriptSDK.Enums/W3CTraceFlags.ts create mode 100644 shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IW3cTraceState.ts create mode 100644 shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts create mode 100644 shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanContext.ts create mode 100644 shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTraceState.ts create mode 100644 shared/AppInsightsCore/src/OpenTelemetry/trace/spanContext.ts create mode 100644 shared/AppInsightsCore/src/OpenTelemetry/trace/traceState.ts diff --git a/.aiAutoMinify.json b/.aiAutoMinify.json index 00f0bc6bf..d4b079210 100644 --- a/.aiAutoMinify.json +++ b/.aiAutoMinify.json @@ -4,6 +4,7 @@ "constEnums": [ "_eSetDynamicPropertyFlags", "CallbackType", + "eTraceStateKeyType", "eEventsDiscardedReason", "eBatchDiscardedReason", "FeatureOptInMode", @@ -15,7 +16,9 @@ "TransportType", "eStatsType", "TelemetryUnloadReason", - "TelemetryUpdateReason" + "TelemetryUpdateReason", + "eTraceHeadersMode", + "eW3CTraceFlags" ] }, "@microsoft/applicationinsights-perfmarkmeasure-js": { diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 03c797bf0..1f3b0395a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -188,17 +188,120 @@ export class MyPlugin extends BaseTelemetryPlugin { - Test both success and failure scenarios - Verify telemetry data structure and content +### Testing Framework Requirements +- **Extend AITestClass**: All test classes must extend `AITestClass` from `@microsoft/ai-test-framework` +- **Use Framework Tools**: Leverage existing framework helpers like `this.hookFetch()`, `this.useFakeTimers()`, and `this.onDone()` +- **Proper Registration**: Implement `registerTests()` method and use `this.testCase()` for test registration +- **Async Tests**: Return `IPromise` from test functions for asynchronous operations (do not use deprecated `testCaseAsync`) + +### Critical Cleanup & Resource Management +- **Mandatory Core Cleanup**: Always call `appInsightsCore.unload(false)` in test cleanup to prevent hook pollution between tests +- **Extension Teardown**: Only call `teardown()` on extension instances that were NOT added to a core instance; `core.unload()` handles teardown for initialized extensions +- **Hook Validation**: The framework validates that all hooks are properly removed; tests will fail if cleanup is incomplete +- **Resource Isolation**: Each test must be completely isolated - no shared state or leftover hooks + +### Configuration Testing Requirements +- **Static Configuration**: Test initial configuration setup and validation +- **Dynamic Configuration**: **REQUIRED** - All tests that touch configuration must include post-initialization configuration change tests +- **onConfigChange Testing**: Components using `onConfigChange` callbacks must be tested for runtime configuration updates +- **Configuration Validation**: Test both valid and invalid configuration scenarios with proper error handling + +```typescript +// Example dynamic configuration test pattern +public testDynamicConfig() { + // Initial setup with one config + let initialConfig = { enableFeature: false }; + core.initialize(initialConfig, channels); + + // Verify initial behavior + Assert.equal(false, component.isFeatureEnabled()); + + // Update configuration dynamically + core.config.enableFeature = true; + // Note: core.onConfigChange() only registers callbacks, it doesn't trigger changes + + // To trigger config change detection, use one of these patterns: + + // Option 1: Using fake timers (synchronous) + this.clock.tick(1); // Trigger 1ms timer for config change detection + + // Option 2: Async test without fake timers + // return createPromise((resolve) => { + // setTimeout(() => { + // Assert.equal(true, component.isFeatureEnabled()); + // resolve(); + // }, 10); + // }); + + // Verify behavior changed (when using fake timers) + Assert.equal(true, component.isFeatureEnabled()); +} +``` + +### Package Organization & Dependencies +- **Respect Package Boundaries**: Place tests in the package that owns the functionality being tested +- **Dependency Injection**: Extensions must include dependencies in `config.extensions` array for proper initialization +- **Cross-Package Coordination**: Understand which package owns which functionality when testing integrated features +- **Import Resolution**: Use proper module imports and avoid direct file path dependencies + +### HTTP API & Network Testing +- **Use Framework Helpers**: Use `this.hookFetch()` instead of custom fetch mocking implementations +- **XMLHttpRequest Testing**: Use framework's built-in mechanisms for XHR validation +- **Header Validation**: Test both presence and absence of headers based on different configuration modes +- **Network Scenarios**: Test success, failure, timeout, and edge cases consistently + +### Async Testing Patterns +- **IPromise Return**: Use `this.testCase()` and return `IPromise` for asynchronous operations instead of deprecated `testCaseAsync` +- **Promise Handling**: Handle both resolution and rejection paths in async tests +- **Timing Control**: Use `this.clock.tick()` when `useFakeTimers: true` for deterministic timing +- **Cleanup in Async**: Ensure cleanup happens in both success and failure paths of async tests + +```typescript +// Example async test pattern +this.testCase({ + name: "Async operation test", + test: () => { + return createPromise((resolve, reject) => { + // Setup async operation + someAsyncOperation().then(() => { + try { + // Assertions + Assert.ok(true, "Operation succeeded"); + resolve(); + } catch (e) { + reject(e); + } + }).catch(reject); + }); + } +}); +``` + +### Unit Testing Best Practices +- **Comprehensive Coverage**: Test all major code paths including edge cases and error conditions +- **Mock Browser APIs**: Mock browser APIs consistently using framework-provided mechanisms +- **Telemetry Validation**: Verify telemetry data structure, content, and proper formatting +- **State Testing**: Test both empty/null states and populated states for data structures + ### Browser Testing -- Cross-browser compatibility testing -- Performance regression testing -- Memory leak detection -- Network failure scenarios +- **Cross-browser Compatibility**: Test across different browser environments and API availability +- **Performance Regression**: Monitor test execution time and detect performance regressions +- **Memory Leak Detection**: Verify proper cleanup and resource management in long-running scenarios +- **API Graceful Degradation**: Test behavior when browser APIs are unavailable or disabled ### Test Organization -- Collocate tests with source code in `Tests/` directories -- Use descriptive test names -- Group related tests in test suites -- Mock external dependencies +- **Collocate Tests**: Place tests in `Tests/` directories within the same package as source code +- **Descriptive Naming**: Use clear, descriptive test names that explain the scenario being tested +- **Logical Grouping**: Group related tests in test suites within the same test class +- **Documentation**: Include comments explaining complex test scenarios and edge cases + +### Common Anti-Patterns to Avoid +- **Skipping Cleanup**: Not calling `unload()` or `teardown()` methods leads to test interference +- **Custom Implementations**: Implementing custom mocks/helpers instead of using framework-provided tools +- **Configuration Gaps**: Testing only static configuration without dynamic configuration change scenarios +- **Hook Pollution**: Leaving hooks active between tests causing false positives/negatives +- **Incomplete Coverage**: Missing edge cases, error conditions, or state transitions +- **Deprecated Async**: Using deprecated `testCaseAsync` instead of `testCase` with `IPromise` return ## Configuration & Initialization diff --git a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts index f24e518bf..5b80016c1 100644 --- a/AISKU/Tests/Unit/src/AISKUSize.Tests.ts +++ b/AISKU/Tests/Unit/src/AISKUSize.Tests.ts @@ -54,10 +54,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKUSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 148; - private readonly MAX_BUNDLE_SIZE = 148; - private readonly MAX_RAW_DEFLATE_SIZE = 59; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 59; + 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 rawFilePath = "../dist/es5/applicationinsights-web.min.js"; // Automatically updated by version scripts private readonly currentVer = "3.3.9"; diff --git a/AISKU/Tests/Unit/src/SnippetInitialization.Tests.ts b/AISKU/Tests/Unit/src/SnippetInitialization.Tests.ts index c81500e07..48054e3ec 100644 --- a/AISKU/Tests/Unit/src/SnippetInitialization.Tests.ts +++ b/AISKU/Tests/Unit/src/SnippetInitialization.Tests.ts @@ -12,7 +12,7 @@ import { utlRemoveSessionStorage, utlSetSessionStorage } from "@microsoft/applicationinsights-common"; import { getGlobal } from "@microsoft/applicationinsights-shims"; -import { TelemetryContext } from "@microsoft/applicationinsights-properties-js"; +import { IPropTelemetryContext } from "@microsoft/applicationinsights-properties-js"; import { dumpObj, objHasOwnProperty, strSubstring } from "@nevware21/ts-utils"; import { AppInsightsSku } from "../../../src/AISku"; @@ -733,6 +733,7 @@ export class SnippetInitializationTests extends AITestClass { Assert.ok(baseData.properties.requestHeaders[RequestHeaders.requestIdHeader], "Request-Id header"); Assert.ok(baseData.properties.requestHeaders[RequestHeaders.requestContextHeader], "Request-Context header"); Assert.ok(baseData.properties.requestHeaders[RequestHeaders.traceParentHeader], "traceparent"); + Assert.ok(!baseData.properties.requestHeaders[RequestHeaders.traceStateHeader], "traceState should not be present in outbound event"); const id: string = baseData.id; const regex = id.match(/\|.{32}\..{16}\./g); Assert.ok(id.length > 0); @@ -865,7 +866,7 @@ export class SnippetInitializationTests extends AITestClass { steps: [ () => { let theSnippet = this._initializeSnippet(snippetCreator(getSnippetConfig(this.sessionPrefix))); - const context = (theSnippet.context) as TelemetryContext; + const context = (theSnippet.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext('10001'); theSnippet.trackTrace({ message: 'authUserContext test' }); } @@ -897,7 +898,7 @@ export class SnippetInitializationTests extends AITestClass { steps: [ () => { let theSnippet = this._initializeSnippet(snippetCreator(getSnippetConfig(this.sessionPrefix))); - const context = (theSnippet.context) as TelemetryContext; + const context = (theSnippet.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext('10001', 'account123'); theSnippet.trackTrace({ message: 'authUserContext test' }); } @@ -928,7 +929,7 @@ export class SnippetInitializationTests extends AITestClass { steps: [ () => { let theSnippet = this._initializeSnippet(snippetCreator(getSnippetConfig(this.sessionPrefix))); - const context = (theSnippet.context) as TelemetryContext; + const context = (theSnippet.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext("\u0428", "\u0429"); theSnippet.trackTrace({ message: 'authUserContext test' }); } @@ -959,7 +960,7 @@ export class SnippetInitializationTests extends AITestClass { steps: [ () => { let theSnippet = this._initializeSnippet(snippetCreator(getSnippetConfig(this.sessionPrefix))); - const context = (theSnippet.context) as TelemetryContext; + const context = (theSnippet.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext('10002', 'account567'); context.user.clearAuthenticatedUserContext(); theSnippet.trackTrace({ message: 'authUserContext test' }); @@ -991,7 +992,7 @@ export class SnippetInitializationTests extends AITestClass { test: () => { // Setup let theSnippet = this._initializeSnippet(snippetCreator(getSnippetConfig(this.sessionPrefix))); - const context = (theSnippet.context) as TelemetryContext; + const context = (theSnippet.context) as IPropTelemetryContext; const authSpy: SinonSpy = this.sandbox.spy(context.user, 'setAuthenticatedUserContext'); let cookieMgr = theSnippet.getCookieMgr(); const cookieSpy: SinonSpy = this.sandbox.spy(cookieMgr, 'set'); diff --git a/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts b/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts index c3fc8830a..f4900dac4 100644 --- a/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts +++ b/AISKU/Tests/Unit/src/applicationinsights.e2e.tests.ts @@ -4,7 +4,7 @@ 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 { ITelemetryItem, getGlobal, newId, dumpObj, BaseTelemetryPlugin, IProcessTelemetryContext, __getRegisteredEvents, arrForEach, IConfiguration, ActiveStatus, FeatureOptInMode } from "@microsoft/applicationinsights-core-js"; -import { TelemetryContext } from '@microsoft/applicationinsights-properties-js'; +import { IPropTelemetryContext } from '@microsoft/applicationinsights-properties-js'; import { createAsyncResolvedPromise } from '@nevware21/ts-async'; import { CONFIG_ENDPOINT_URL } from '../../../src/InternalConstants'; import { OfflineChannel } from '@microsoft/applicationinsights-offlinechannel-js'; @@ -1660,6 +1660,7 @@ export class ApplicationInsightsTests extends AITestClass { Assert.ok(baseData.properties.requestHeaders[RequestHeaders.requestIdHeader], "Request-Id header"); Assert.ok(baseData.properties.requestHeaders[RequestHeaders.requestContextHeader], "Request-Context header"); Assert.ok(baseData.properties.requestHeaders[RequestHeaders.traceParentHeader], "traceparent"); + Assert.ok(!baseData.properties.requestHeaders[RequestHeaders.traceStateHeader], "traceState should not be present in outbound event"); const id: string = baseData.id; const regex = id.match(/\|.{32}\..{16}\./g); Assert.ok(id.length > 0); @@ -1788,7 +1789,7 @@ export class ApplicationInsightsTests extends AITestClass { stepDelay: 1, steps: [ () => { - const context = (this._ai.context) as TelemetryContext; + const context = (this._ai.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext('10001'); this._ai.trackTrace({ message: 'authUserContext test' }); } @@ -1819,7 +1820,7 @@ export class ApplicationInsightsTests extends AITestClass { stepDelay: 1, steps: [ () => { - const context = (this._ai.context) as TelemetryContext; + const context = (this._ai.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext('10001', 'account123'); this._ai.trackTrace({ message: 'authUserContext test' }); } @@ -1849,7 +1850,7 @@ export class ApplicationInsightsTests extends AITestClass { stepDelay: 1, steps: [ () => { - const context = (this._ai.context) as TelemetryContext; + const context = (this._ai.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext("\u0428", "\u0429"); this._ai.trackTrace({ message: 'authUserContext test' }); } @@ -1879,7 +1880,7 @@ export class ApplicationInsightsTests extends AITestClass { stepDelay: 1, steps: [ () => { - const context = (this._ai.context) as TelemetryContext; + const context = (this._ai.context) as IPropTelemetryContext; context.user.setAuthenticatedUserContext('10002', 'account567'); context.user.clearAuthenticatedUserContext(); this._ai.trackTrace({ message: 'authUserContext test' }); @@ -1910,7 +1911,7 @@ export class ApplicationInsightsTests extends AITestClass { name: 'AuthenticatedUserContext: setAuthenticatedUserContext does not set the cookie by default', test: () => { // Setup - const context = (this._ai.context) as TelemetryContext; + const context = (this._ai.context) as IPropTelemetryContext; const authSpy: SinonSpy = this.sandbox.spy(context.user, 'setAuthenticatedUserContext'); let cookieMgr = this._ai.getCookieMgr(); const cookieSpy: SinonSpy = this.sandbox.spy(cookieMgr, 'set'); diff --git a/AISKU/src/AISku.ts b/AISKU/src/AISku.ts index 51a14297b..b09c37d76 100644 --- a/AISKU/src/AISku.ts +++ b/AISKU/src/AISku.ts @@ -790,7 +790,7 @@ export class AppInsightsSku implements IApplicationInsights { /** * Manually trigger an immediate send of all telemetry still in the buffer using beacon Sender. * Fall back to xhr sender if beacon is not supported. - * @param [async=true] + * @param async - send data asynchronously when true, default is true */ public onunloadFlush(async: boolean = true) { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging diff --git a/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts b/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts index a8d3baf45..3adcf8e40 100644 --- a/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts +++ b/AISKULight/Tests/Unit/src/AISKULightSize.Tests.ts @@ -51,10 +51,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AISKULightSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 93; - private readonly MAX_BUNDLE_SIZE = 94; - private readonly MAX_RAW_DEFLATE_SIZE = 39; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 39; + private readonly MAX_RAW_SIZE = 99; + private readonly MAX_BUNDLE_SIZE = 99; + private readonly MAX_RAW_DEFLATE_SIZE = 42; + private readonly MAX_BUNDLE_DEFLATE_SIZE = 42; private readonly rawFilePath = "../dist/es5/applicationinsights-web-basic.min.js"; private readonly currentVer = "3.3.9"; private readonly prodFilePath = `../browser/es5/aib.${this.currentVer[0]}.min.js`; diff --git a/RELEASES.md b/RELEASES.md index 11ec54e4d..12bc82c9b 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -2,6 +2,68 @@ > Note: ES3/IE8 compatibility will be removed in the future v3.x.x releases (scheduled for mid-late 2022), so if you need to retain ES3 compatibility you will need to remain on the 2.x.x versions of the SDK or your runtime will need install polyfill's to your ES3 environment before loading / initializing the SDK. +## 3.4.0-beta (Unreleased) + +### Significant Changes + +- **W3C Trace State Support**: Added full support for managing W3C Trace State and sending headers in distributed tracing, including new distributed tracing modes `AI_AND_W3C_TRACE` and `W3C_TRACE` that enable the [`tracestate`](https://www.w3.org/TR/trace-context/#tracestate-header) header to be sent with requests when trace state information is available, the existing states will continue to not send the header. + +- **New Distributed Tracing Modes**: Added new `eDistributedTracingModes` enum values: + - `AI_AND_W3C_TRACE` (17): Sends Application Insights headers + W3C `traceparent` + W3C `tracestate` headers (if state value is present) + - `W3C_TRACE` (18): Sends only W3C `traceparent` + W3C `tracestate` headers (if state value is present) + +- **Enhanced Distributed Tracing**: Refactored the distributed tracing implementation to provide better support for the W3C Trace Context specification and prepare for future OpenTelemetry Span-style API integration. + +- **New W3C TraceState API**: Introduced the `IW3cTraceState` interface that provides a mutable, ordered list of key/value pairs for trace state information with proper parent-child relationships. + +- **OpenTelemetry Integration Preparation**: Added foundational OpenTelemetry interfaces (`IOTelSpanContext`, `IOTelTraceState`) to provide OpenTelemetry API compatibility. + +- **Additional Configuration**: Added new configuration properties for W3C trace state support: + - `traceHdrMode`: Controls if the SDK should look for the `traceparent` and/or `tracestate` values from service timing headers or meta tags from the initial page load (in `IConfiguration`) + - Enhanced `distributedTracingMode` property to support the new W3C trace state modes (in `ICorrelationConfig`) + +- **Dependencies Extension**: The dependency tracking extension now includes additional logic for W3C trace state handling, which may affect custom dependency listeners or initializers. The following interfaces and functions have been enhanced with W3C trace state support: + - `IDependencyListenerDetails` interface now also includes a readonly `traceState` along with the previous `traceId`, `spanId`, `traceFlags` properties + - `addDependencyListener()` function now provides access to W3C trace state information through the enhanced details object + - `addDependencyInitializer()` function continues to work with existing dependency telemetry processing + - Custom dependency listeners can now access and modify W3C trace state information before requests are sent + +### Breaking Changes + +The following is a list of known breaking changes for anyone attempting to implement the interfaces, for end-users / consumers of the existing interface this is considered to be only a potential breaking change as the existing functions are still provided and provide the same level of functionality. The breaking nature of these changes is for anyone attempting to provide their own implementation of these changes. + +#### Interface Changes + +- The `IDistributedTraceContext` interface has been significantly expanded to include W3C trace state management capabilities, which may affect custom telemetry processors that interact with distributed tracing context. + - Added additional "required" property accessors which update ONLY the current trace context instance and DO NOT update any parent context instances (`pageName`, `traceId`, `spanId` and `traceFlags`). + - The previous set functions continue to also update (replace) any parent context values for existing backward compatability, but have been marked as depracted and will be removed in a future release due to their side-effects of overwriting the parent values. + +### Potential Breaking Changes + +- **Class Removal**: The `TelemetryTrace` class has been removed and is no longer exported as part of the distributed tracing refactoring, with its functionality integrated into the new W3C trace state implementation. + - The properties `telemetryTrace` is now a complete adpater to the existing `core.getTraceCtx()` value and as such is now marked as deprecated and will be removed in a future release. + - The value of the `appInsights.context.telemetryTrace` is no longer an instance of this removed class. + +- **Trace Context Initialization**: Due to the distributed tracing refactoring, the core instance and SDK will now always have a valid `traceId` available through `core.getTraceCtx()`. The `traceId` will be either a newly generated random value or inherited from any detected parent trace context. This ensures consistent trace context availability but may affect applications that previously relied on the absence of a `traceId` to determine if distributed tracing was active. + +- **Dependencies Extension - ajaxRecord Class Removal**: The internal `ajaxRecord` class has been removed and is no longer exported from the dependencies extension (`@microsoft/applicationinsights-dependencies-js`). This class was previously used internally for AJAX request tracking and was referenced in the `IInstrumentationRequirements.includeCorrelationHeaders()` function signature. **Important**: The previous exporting of the `ajaxRecord` class was unintentional and was never meant to be part of the public API - it was an internal implementation detail that inadvertently became accessible to external code. + - **Previous Signature**: `includeCorrelationHeaders(ajaxData: ajaxRecord, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented): any` + - **New Signature**: `includeCorrelationHeaders(ajaxData: IAjaxRecordData, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented): any` + - **New Interface**: The `IAjaxRecordData` interface has been introduced to replace the `ajaxRecord` class in public API signatures and provides access to essential AJAX request properties: + - `getAbsoluteUrl(): string | null` - Gets the absolute URL for the request + - `getPathName(): string | null` - Gets the sanitized path name for the request URL + - `traceCtx: IDistributedTraceContext` - The distributed trace context for the request + - `requestHeaders: { [key: string]: string }` - Object containing request headers + - `aborted?: number` - Indicates whether the request was aborted + - `context?: { [key: string]: any }` - Optional context object for dependency listeners + - **Impact**: This change only affects custom implementations that directly referenced the `ajaxRecord` class or implemented the `IInstrumentationRequirements` interface. Standard SDK usage and most custom dependency listeners/initializers are unaffected. + - **Migration**: If your code previously referenced `ajaxRecord` or implemented `IInstrumentationRequirements`, update it to use the new `IAjaxRecordData` interface, which provides the same essential properties with proper TypeScript definitions and comprehensive JSDoc documentation. + - **Need Help?**: If you discover that your code depends on other functions or properties from the dependencies extension that are no longer exported and you believe should be part of the public API, please [raise an issue](https://github.com/microsoft/ApplicationInsights-JS/issues) with details about your use case so we can review and potentially provide a proper public API alternative. + +### Changelog + +- [Beta] Add W3c Trace State support / handling and refactor distributed trace handling to prepare for OptenTelemetry Span style API / management + ## 3.3.9 (June 25th, 2025) This release contains an important fix for a change introduced in v3.3.7 that caused the `autoCaptureHandler` to incorrectly evaluate elements within `trackElementsType`, resulting in some click events not being auto-captured. See more details [here](https://github.com/microsoft/ApplicationInsights-JS/issues/2589). diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index b504de762..882ef6fe7 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -208,9 +208,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.6", @@ -234,18 +234,18 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" @@ -321,9 +321,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -342,30 +342,18 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "peer": true, "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -428,9 +416,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" }, "node_modules/@microsoft/api-extractor": { "version": "7.52.8", @@ -1695,9 +1683,9 @@ "peer": true }, "node_modules/@types/lodash": { - "version": "4.17.18", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz", - "integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==" + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==" }, "node_modules/@types/mdast": { "version": "4.0.4", @@ -1708,9 +1696,13 @@ } }, "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==" + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-6.0.0.tgz", + "integrity": "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==", + "deprecated": "This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.", + "dependencies": { + "minimatch": "*" + } }, "node_modules/@types/node": { "version": "11.13.2", @@ -1775,16 +1767,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz", - "integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz", + "integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==", "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.35.0", - "@typescript-eslint/type-utils": "8.35.0", - "@typescript-eslint/utils": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0", + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/type-utils": "8.37.0", + "@typescript-eslint/utils": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1798,21 +1790,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.35.0", + "@typescript-eslint/parser": "^8.37.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz", - "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz", + "integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.35.0", - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/typescript-estree": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0", + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4" }, "engines": { @@ -1828,13 +1820,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz", - "integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz", + "integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==", "peer": true, "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.35.0", - "@typescript-eslint/types": "^8.35.0", + "@typescript-eslint/tsconfig-utils": "^8.37.0", + "@typescript-eslint/types": "^8.37.0", "debug": "^4.3.4" }, "engines": { @@ -1849,13 +1841,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz", - "integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz", + "integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==", "peer": true, "dependencies": { - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0" + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1866,9 +1858,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz", - "integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz", + "integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1882,13 +1874,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz", - "integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz", + "integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==", "peer": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.35.0", - "@typescript-eslint/utils": "8.35.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/utils": "8.37.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1905,9 +1898,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", - "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz", + "integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==", "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1918,15 +1911,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz", - "integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz", + "integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==", "peer": true, "dependencies": { - "@typescript-eslint/project-service": "8.35.0", - "@typescript-eslint/tsconfig-utils": "8.35.0", - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/visitor-keys": "8.35.0", + "@typescript-eslint/project-service": "8.37.0", + "@typescript-eslint/tsconfig-utils": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1982,15 +1975,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz", - "integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz", + "integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.35.0", - "@typescript-eslint/types": "8.35.0", - "@typescript-eslint/typescript-estree": "8.35.0" + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2005,12 +1998,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz", - "integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz", + "integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==", "peer": true, "dependencies": { - "@typescript-eslint/types": "8.35.0", + "@typescript-eslint/types": "8.37.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2065,9 +2058,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "engines": { "node": ">= 14" } @@ -2335,15 +2328,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz", + "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==", "optional": true }, "node_modules/bare-fs": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", - "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", + "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", "optional": true, "dependencies": { "bare-events": "^2.5.4", @@ -2459,9 +2452,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "funding": [ { "type": "opencollective", @@ -2477,8 +2470,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -2529,9 +2522,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001724", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz", - "integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==", + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "funding": [ { "type": "opencollective", @@ -2909,9 +2902,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.5.173", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.173.tgz", - "integrity": "sha512-2bFhXP2zqSfQHugjqJIDFVwa+qIxyNApenmXTp9EjaKtdPrES5Qcn9/aSFy/NaP2E+fWG/zxKu/LBvY36p5VNQ==" + "version": "1.5.183", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.183.tgz", + "integrity": "sha512-vCrDBYjQCAEefWGjlK3EpoSKfKbT10pR4XXPdn65q7snuNOZnthoVpBfZPykmDapOKfoD+MMIPG8ZjKyyc9oHA==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -3012,18 +3005,18 @@ } }, "node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", + "@eslint/js": "9.31.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3567,6 +3560,19 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3598,9 +3604,9 @@ } }, "node_modules/get-uri": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", - "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", @@ -6081,9 +6087,9 @@ } }, "node_modules/socks": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz", - "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", + "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -6262,9 +6268,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz", - "integrity": "sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" @@ -6703,9 +6709,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "engines": { "node": ">=10.0.0" }, @@ -6857,9 +6863,9 @@ } }, "node_modules/zod": { - "version": "3.25.67", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", - "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/examples/dependency/src/dependencies-example-index.ts b/examples/dependency/src/dependencies-example-index.ts index 110c85684..d59b845dd 100644 --- a/examples/dependency/src/dependencies-example-index.ts +++ b/examples/dependency/src/dependencies-example-index.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { arrForEach } from "@microsoft/applicationinsights-core-js"; +import { arrForEach, eW3CTraceFlags } from "@microsoft/applicationinsights-core-js"; import { addDependencyListener, addDependencyInitializer, stopDependencyEvent, changeConfig, initApplicationInsights, getConfig, enableAjaxPerfTrackingConfig } from "./appinsights-init"; import { addHandlersButtonId, ajaxCallId, buttonSectionId, changeConfigButtonId, clearDetailsButtonId, clearDetailsList, clearEle, configContainerId, configDetails, createButton, createContainers, createDetailList, createFetchRequest, createUnTrackRequest, createXhrRequest, dependencyInitializerDetails, dependencyInitializerDetailsContainerId, dependencyListenerButtonId, dependencyListenerDetails, dependencyListenerDetailsContainerId, fetchCallId, fetchXhrId, removeAllHandlersId, stopDependencyEventButtonId, untrackFetchRequestId } from "./utils"; @@ -16,7 +16,7 @@ function addDependencyListenerOnclick() { console.log(details); // Add additional context values (any) that can be used by other listeners and is also passed to any dependency initializers details.context.listener = "dependency-listener-context"; - details.traceFlags = 0; + details.traceFlags = eW3CTraceFlags.None; createDetailList(dependencyListenerDetails, details, dependencyListenerDetailsContainerId, "Listener"); }); diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts index a8cecd37a..556d8702c 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts @@ -8,20 +8,18 @@ import { AnalyticsPluginIdentifier, Event as EventTelemetry, Exception, IAppInsights, IAutoExceptionTelemetry, IConfig, IDependencyTelemetry, IEventTelemetry, IExceptionInternal, IExceptionTelemetry, IMetricTelemetry, IPageViewPerformanceTelemetry, IPageViewPerformanceTelemetryInternal, IPageViewTelemetry, IPageViewTelemetryInternal, ITraceTelemetry, Metric, PageView, - PageViewPerformance, PropertiesPluginIdentifier, RemoteDependencyData, Trace, createDistributedTraceContextFromTrace, createDomEvent, - createTelemetryItem, dataSanitizeString, eSeverityLevel, isCrossOriginError, strNotSpecified, utlDisableStorage, utlEnableStorage, - utlSetStoragePrefix + PageViewPerformance, RemoteDependencyData, Trace, createDomEvent, createTelemetryItem, dataSanitizeString, eSeverityLevel, + isCrossOriginError, strNotSpecified, utlDisableStorage, utlEnableStorage, utlSetStoragePrefix } from "@microsoft/applicationinsights-common"; import { - 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, - 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, 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 } from "@microsoft/applicationinsights-core-js"; -import { PropertiesPlugin } from "@microsoft/applicationinsights-properties-js"; import { isArray, isError, objDeepFreeze, objDefine, scheduleTimeout, strIndexOf } from "@nevware21/ts-utils"; import { IAnalyticsConfig } from "./Interfaces/IAnalyticsConfig"; import { IAppInsightsInternal, PageViewManager } from "./Telemetry/PageViewManager"; @@ -764,26 +762,6 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights })); } - function _getDistributedTraceCtx(): IDistributedTraceContext { - let distributedTraceCtx: IDistributedTraceContext = null; - if (_self.core && _self.core.getTraceCtx) { - distributedTraceCtx = _self.core.getTraceCtx(false); - } - - if (!distributedTraceCtx) { - // Fallback when using an older Core and PropertiesPlugin - let properties = _self.core.getPlugin(PropertiesPluginIdentifier); - if (properties) { - let context = properties.plugin.context; - if (context) { - distributedTraceCtx = createDistributedTraceContextFromTrace(context.telemetryTrace); - } - } - } - - return distributedTraceCtx; - } - /** * Create a custom "locationchange" event which is triggered each time the history object is changed */ @@ -813,16 +791,18 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights _currUri = fieldRedaction(_currUri, _self.core.config); } if (_enableAutoRouteTracking) { - let distributedTraceCtx = _getDistributedTraceCtx(); - if (distributedTraceCtx) { - distributedTraceCtx.setTraceId(generateW3CId()); - let traceLocationName = "_unknown_"; - if (locn && locn.pathname) { - traceLocationName = locn.pathname + (locn.hash || ""); - } - // This populates the ai.operation.name which has a maximum size of 1024 so we need to sanitize it - distributedTraceCtx.setName(dataSanitizeString(_self.diagLog(), traceLocationName)); + // 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()); + + // 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_"); + if (_self.core && _self.core.getTraceCtx) { + _self.core.setTraceCtx(newContext); } scheduleTimeout(((uri: string) => { diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts index a2357ef45..17ee521a9 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts @@ -42,10 +42,10 @@ export class PageVisitTimeManager { /** * Stops timing of current page (if exists) and starts timing for duration of visit to pageName * @param pageName - Name of page to begin timing visit duration - * @returns {PageVisitData} Page visit data (including duration) of pageName from last call to start or restart, if exists. Null if not. + * @returns {IPageVisitData} Page visit data (including duration) of pageName from last call to start or restart, if exists. Null if not. */ function restartPageVisitTimer(pageName: string, pageUrl: string) { - let prevPageVisitData: PageVisitData = null; + let prevPageVisitData: IPageVisitData = null; try { prevPageVisitData = stopPageVisitTimer(); if (utlCanUseSessionStorage()) { @@ -53,7 +53,7 @@ export class PageVisitTimeManager { throwError("Cannot call startPageVisit consecutively without first calling stopPageVisit"); } - const currPageVisitDataStr = getJSON().stringify(new PageVisitData(pageName, pageUrl)); + const currPageVisitDataStr = getJSON().stringify(createPageVisitData(pageName, pageUrl)); utlSetSessionStorage(logger, prevPageVisitDataKeyName, currPageVisitDataStr); } @@ -67,10 +67,10 @@ export class PageVisitTimeManager { /** * Stops timing of current page, if exists. - * @returns {PageVisitData} Page visit data (including duration) of pageName from call to start, if exists. Null if not. + * @returns {IPageVisitData} Page visit data (including duration) of pageName from call to start, if exists. Null if not. */ function stopPageVisitTimer() { - let prevPageVisitData: PageVisitData = null; + let prevPageVisitData: IPageVisitData = null; try { if (utlCanUseSessionStorage()) { @@ -113,16 +113,24 @@ export class PageVisitTimeManager { } } -export class PageVisitData { - - public pageName: string; - public pageUrl: string; - public pageVisitStartTime: number; - public pageVisitTime: number; +export interface IPageVisitData { + pageName: string; + pageUrl: string; + pageVisitStartTime: number; + pageVisitTime: number; +} - constructor(pageName: string, pageUrl: string) { - this.pageVisitStartTime = dateNow(); - this.pageName = pageName; - this.pageUrl = pageUrl; - } +/** + * Factory function to create a page visit data object + * @param pageName - Name of the page + * @param pageUrl - URL of the page + * @returns IPageVisitData instance + */ +export function createPageVisitData(pageName: string, pageUrl: string): IPageVisitData { + return { + pageVisitStartTime: dateNow(), + pageName: pageName, + pageUrl: pageUrl, + pageVisitTime: 0 + }; } 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 54f7c25d0..ad567a8c5 100644 --- a/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts +++ b/extensions/applicationinsights-cfgsync-js/Tests/Unit/src/cfgsynchelper.tests.ts @@ -106,7 +106,8 @@ export class CfgSyncHelperTests extends AITestClass { // endCfg: [] // } //}, - enableDebug: false, + traceHdrMode: 3, + enableDebug: false } let core = new AppInsightsCore(); diff --git a/extensions/applicationinsights-dependencies-js/Tests/Unit/src/TestChannelPlugin.ts b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/TestChannelPlugin.ts new file mode 100644 index 000000000..03605a813 --- /dev/null +++ b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/TestChannelPlugin.ts @@ -0,0 +1,54 @@ +import { IConfiguration, IChannelControls, ITelemetryItem, ITelemetryPlugin, ITelemetryPluginChain } from "@microsoft/applicationinsights-core-js"; + +/** + * TestChannelPlugin for testing - a minimal implementation of IChannelControls + * that can be used as a mock channel in tests + */ +export class TestChannelPlugin implements IChannelControls { + public _nextPlugin: ITelemetryPlugin; + public version: string = "1.0.0-test"; + public processTelemetry; + public identifier: string; + public priority: number = 1001; + + constructor(identifier: string = "TestChannelPlugin") { + this.identifier = identifier; + this.processTelemetry = this._processTelemetry.bind(this); + } + + public pause(): void { + // No-op for testing + } + + public resume(): void { + // No-op for testing + } + + public teardown(): void { + // No-op for testing + } + + flush(async?: boolean, callBack?: () => void): void { + if (callBack) { + callBack(); + } + } + + onunloadFlush(async?: boolean) { + // No-op for testing + } + + setNextPlugin(next: ITelemetryPlugin | ITelemetryPluginChain) { + this._nextPlugin = next as ITelemetryPlugin; + } + + public initialize = (config: IConfiguration) => { + // No-op for testing + } + + public _processTelemetry(env: ITelemetryItem) { + if (this._nextPlugin) { + this._nextPlugin.processTelemetry(env); + } + } +} diff --git a/extensions/applicationinsights-dependencies-js/Tests/Unit/src/W3CTraceStateDependency.tests.ts b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/W3CTraceStateDependency.tests.ts new file mode 100644 index 000000000..b5ab860b9 --- /dev/null +++ b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/W3CTraceStateDependency.tests.ts @@ -0,0 +1,989 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { eDistributedTracingModes } from "@microsoft/applicationinsights-common"; +import { RequestHeaders } from "@microsoft/applicationinsights-common"; +import { AppInsightsCore } from "@microsoft/applicationinsights-core-js"; +import { createPromise } from "@nevware21/ts-async"; + +import { AjaxMonitor } from "../../../src/ajax"; +import { TestChannelPlugin } from "./TestChannelPlugin"; + +/** + * Safe unload function for core instances + */ +function _safeUnloadCore(core: AppInsightsCore) { + if (core && core.isInitialized()) { + core.unload(false); + } +} + +/** + * Helper to ensure a tracestate value exists for testing + */ +function _ensureTraceStateValue(core: AppInsightsCore) { + const traceCtx = core.getTraceCtx(); + if (traceCtx && traceCtx.traceState) { + // Manually add a test tracestate value if one doesn't exist + if (!traceCtx.traceState.toString()) { + traceCtx.traceState.set("test", "value"); + } + } +} + +export class W3CTraceStateDependencyTests extends AITestClass { + private _ajax: AjaxMonitor | undefined; + private _context: { [key: string]: any }; + + public testInitialize() { + this.useFakeServer = true; + this._context = {}; + } + + public testCleanup() { + // Retrieve any core instance stored in the context and unload it + if (this._context && this._context.core) { + _safeUnloadCore(this._context.core); + this._context.core = null; + } + + if (this._ajax) { + this._ajax.teardown(); + this._ajax = undefined; + } + + this._context = {}; + } + + public registerTests() { + this.testCase({ + name: "W3CTraceStateDependency: XMLHttpRequest with W3C_TRACE mode includes tracestate header when value present", + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.W3C_TRACE + } + } + }; + + // Initialize the core with the Ajax monitor as an extension + appInsightsCore.initialize(coreConfig, []); + + // Store the core instance for cleanup + this._context.core = appInsightsCore; + + // Explicitly set a tracestate value to ensure the header is included + _ensureTraceStateValue(appInsightsCore); + + // Set window host for testing + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act + var xhr = new XMLHttpRequest(); + var spy = this.sandbox.spy(xhr, "setRequestHeader"); + xhr.open("GET", "http://www.example.com"); + xhr.send(); + + // Assert that only W3C headers are sent (tracestate included because there's a value) + Assert.equal(false, spy.calledWith(RequestHeaders.requestIdHeader), "AI header should not be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should be present with value"); + + // Emulate response so perf monitoring is cleaned up + (xhr as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: XMLHttpRequest with W3C_TRACE mode but no tracestate value", + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.W3C_TRACE + } + } + }; + + // Initialize the core with the Ajax monitor as an extension + appInsightsCore.initialize(coreConfig, []); + + // Set window host for testing + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act + var xhr = new XMLHttpRequest(); + var spy = this.sandbox.spy(xhr, "setRequestHeader"); + xhr.open("GET", "http://www.example.com"); + xhr.send(); + + // Assert that only traceparent header is sent, not AI or tracestate headers + Assert.equal(false, spy.calledWith(RequestHeaders.requestIdHeader), "AI header should not be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(false, spy.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present when no value"); + + // Emulate response so perf monitoring is cleaned up + (xhr as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: XMLHttpRequest with W3C_TRACE mode - dynamic configuration change", + useFakeTimers: true, + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.AI // Start with AI mode + } + } + }; + + // Initialize the core with the Ajax monitor as an extension + appInsightsCore.initialize(coreConfig, []); + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Test initial AI mode + var xhr1 = new XMLHttpRequest(); + var spy1 = this.sandbox.spy(xhr1, "setRequestHeader"); + xhr1.open("GET", "http://www.example.com"); + xhr1.send(); + + // Assert AI mode behavior + Assert.equal(true, spy1.calledWith(RequestHeaders.requestIdHeader), "AI header should be present initially"); + Assert.equal(false, spy1.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should not be present initially"); + Assert.equal(false, spy1.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present initially"); + + // Emulate response + (xhr1 as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Update configuration dynamically to W3C_TRACE mode + if (appInsightsCore.config.extensionConfig) { + appInsightsCore.config.extensionConfig[this._ajax.identifier].distributedTracingMode = eDistributedTracingModes.W3C_TRACE; + } + + // Trigger config change detection using fake timers + this.clock.tick(1); + + // Ensure tracestate value exists after config change + _ensureTraceStateValue(appInsightsCore); + + // Test after dynamic configuration change + var xhr2 = new XMLHttpRequest(); + var spy2 = this.sandbox.spy(xhr2, "setRequestHeader"); + xhr2.open("GET", "http://www.example.com"); + xhr2.send(); + + // Assert W3C_TRACE mode behavior after config change + Assert.equal(false, spy2.calledWith(RequestHeaders.requestIdHeader), "AI header should not be present after config change"); + Assert.equal(true, spy2.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present after config change"); + Assert.equal(true, spy2.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should be present after config change"); + + // Emulate response + (xhr2 as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: verify AI_AND_W3C mode does not include tracestate header", + test: () => { + // Create a fresh AjaxMonitor + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.AI_AND_W3C + } + } + }; + + // Initialize core with AjaxMonitor as extension + appInsightsCore.initialize(coreConfig, []); + + // Set window host for testing + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act + var xhr = new XMLHttpRequest(); + var spy = this.sandbox.spy(xhr, "setRequestHeader"); + xhr.open("GET", "http://www.example.com"); + xhr.send(); + + // Assert that AI and traceparent headers are sent, but not tracestate + Assert.equal(true, spy.calledWith(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(false, spy.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present"); + + // Emulate response so perf monitoring is cleaned up + (xhr as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: verify AI mode does not include any W3C headers", + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.AI + } + } + }; + appInsightsCore.initialize(coreConfig, []); + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act + var xhr = new XMLHttpRequest(); + var spy = this.sandbox.spy(xhr, "setRequestHeader"); + xhr.open("GET", "http://www.example.com"); + xhr.send(); + + // Assert that only AI header is sent, no W3C headers + Assert.equal(true, spy.calledWith(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(false, spy.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should not be present"); + Assert.equal(false, spy.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present"); + + // Emulate response so perf monitoring is cleaned up + (xhr as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: verify AI_AND_W3C_TRACE mode includes tracestate header when value present", + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.AI_AND_W3C_TRACE + } + } + }; + appInsightsCore.initialize(coreConfig, []); + + // Store the core instance for cleanup + this._context.core = appInsightsCore; + + // Explicitly set a tracestate value to ensure the header is included + _ensureTraceStateValue(appInsightsCore); + + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act + var xhr = new XMLHttpRequest(); + var spy = this.sandbox.spy(xhr, "setRequestHeader"); + xhr.open("GET", "http://www.example.com"); + xhr.send(); + + // Assert that all three headers are sent (tracestate included because there's a value) + Assert.equal(true, spy.calledWith(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should be present with value"); + + // Emulate response so perf monitoring is cleaned up + (xhr as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + } + }); + + // Fetch API Tests using framework helpers + this.testCase({ + name: "W3CTraceStateDependency: Fetch with AI_AND_W3C_TRACE mode includes tracestate header when value present", + test: () => { + return createPromise((resolve, reject) => { + try { + // Setup mock fetch using framework helper + const fetchCalls = this.hookFetch((resolveResponse) => { + setTimeout(() => { + resolveResponse({ + headers: new Headers(), + ok: true, + body: null, + bodyUsed: false, + redirected: false, + status: 200, + statusText: "Hello", + trailer: null, + type: "basic", + url: "https://httpbin.org/status/200" + } as any); + }, 0); + }); + + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + disableFetchTracking: false, + disableAjaxTracking: false, + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.AI_AND_W3C_TRACE + } + } + }; + appInsightsCore.initialize(coreConfig, []); + let trackSpy = this.sandbox.spy(appInsightsCore, "track"); + + // Store the core instance for cleanup + this._context.core = appInsightsCore; + + // Explicitly set a tracestate value to ensure the header is included + _ensureTraceStateValue(appInsightsCore); + + // Use test hook to simulate the correct url location + this._ajax["_currentWindowHost"] = "httpbin.org"; + + // Setup + let headers = new Headers(); + headers.append('My-Header', 'Header field'); + let init = { + method: 'get', + headers: headers + }; + const url = 'https://httpbin.org/status/200'; + + // Act + Assert.ok(trackSpy.notCalled, "No fetch called yet"); + fetch(url, init).then(() => { + try { + // Assert + Assert.ok(trackSpy.called, "The request was tracked"); + + Assert.equal(1, fetchCalls.length); + Assert.notEqual(undefined, fetchCalls[0].init, "Has init param"); + + // Get headers - handle both Headers object and plain object cases + let fetchHeaders = fetchCalls[0].init.headers; + let hasHeader = (name: string) => { + if (fetchHeaders instanceof Headers) { + return fetchHeaders.has(name); + } else if (typeof fetchHeaders === 'object') { + return !!fetchHeaders[name]; + } + return false; + }; + + // Check all headers are present (tracestate included because there's a value) + Assert.equal(true, hasHeader(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(true, hasHeader(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(true, hasHeader(RequestHeaders.traceStateHeader), "W3c tracestate header should be present"); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + + resolve(); + } catch (e) { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(e); + } + }).catch((err) => { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(new Error("fetch failed! " + (err ? err.toString() : ""))); + }); + } catch (ex) { + reject(ex); + } + }); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: Fetch with W3C_TRACE mode includes tracestate header when value present", + test: () => { + return createPromise((resolve, reject) => { + try { + // Setup mock fetch using framework helper + const fetchCalls = this.hookFetch((resolveResponse) => { + setTimeout(() => { + resolveResponse({ + headers: new Headers(), + ok: true, + body: null, + bodyUsed: false, + redirected: false, + status: 200, + statusText: "Hello", + trailer: null, + type: "basic", + url: "https://httpbin.org/status/200" + } as any); + }, 0); + }); + + // Create a fresh AjaxMonitor for each test + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + disableFetchTracking: false, + disableAjaxTracking: false, + extensions: [this._ajax], // This is critical - add as an extension + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.W3C_TRACE + } + } + }; + + // Initialize the core with the proper plugins + appInsightsCore.initialize(coreConfig, []); + let trackSpy = this.sandbox.spy(appInsightsCore, "track"); + + // Store the core instance for cleanup + this._context.core = appInsightsCore; + + // Explicitly set a tracestate value to ensure the header is included + _ensureTraceStateValue(appInsightsCore); + + // Use test hook to simulate the correct url location + this._ajax["_currentWindowHost"] = "httpbin.org"; + + // Setup + let headers = new Headers(); + headers.append('My-Header', 'Header field'); + let init = { + method: 'get', + headers: headers + }; + const url = 'https://httpbin.org/status/200'; + + // Act + Assert.ok(trackSpy.notCalled, "No fetch called yet"); + fetch(url, init).then(() => { + try { + // Assert + Assert.ok(trackSpy.called, "The request was tracked"); + Assert.equal(1, fetchCalls.length); + Assert.notEqual(undefined, fetchCalls[0].init, "Has init param"); + + // Get headers - handle both Headers object and plain object cases + let fetchHeaders = fetchCalls[0].init.headers; + let hasHeader = (name: string) => { + if (fetchHeaders instanceof Headers) { + return fetchHeaders.has(name); + } else if (typeof fetchHeaders === 'object') { + return !!fetchHeaders[name]; + } + return false; + }; + + // Check W3C headers are present but not AI headers (tracestate included because there's a value) + Assert.equal(false, hasHeader(RequestHeaders.requestIdHeader), "AI header should not be present"); + Assert.equal(true, hasHeader(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(true, hasHeader(RequestHeaders.traceStateHeader), "W3c tracestate header should be present with value"); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + + resolve(); + } catch (e) { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(e); + } + }).catch((err) => { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(new Error("fetch failed! " + (err ? err.toString() : ""))); + }); + } catch (ex) { + reject(ex); + } + }); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: Fetch with W3C_TRACE mode but no tracestate value", + test: () => { + return createPromise((resolve, reject) => { + try { + // Use the framework hookFetch helper + const fetchCalls = this.hookFetch((resolveResponse) => { + setTimeout(() => { + resolveResponse({ + headers: new Headers(), + ok: true, + body: null, + bodyUsed: false, + redirected: false, + status: 200, + statusText: "Hello", + trailer: null, + type: "basic", + url: "https://httpbin.org/status/200" + } as any); + }, 0); + }); + + // Create a fresh AjaxMonitor for each test + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + disableFetchTracking: false, + disableAjaxTracking: false, + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + // No appId here, so there should be no tracestate value + distributedTracingMode: eDistributedTracingModes.W3C_TRACE + } + } + }; + + // Store the core instance in context for cleanup + this._context.core = appInsightsCore; + + // Initialize the core with the proper plugins + appInsightsCore.initialize(coreConfig, []); + let trackSpy = this.sandbox.spy(appInsightsCore, "track"); + + // Use test hook to simulate the correct url location + this._ajax["_currentWindowHost"] = "httpbin.org"; + + // Setup + let headers = new Headers(); + headers.append('My-Header', 'Header field'); + let init = { + method: 'get', + headers: headers + }; + const url = 'https://httpbin.org/status/200'; + + // Act + Assert.ok(trackSpy.notCalled, "No fetch called yet"); + fetch(url, init).then(() => { + try { + // Assert + Assert.ok(trackSpy.called, "The request was tracked"); + Assert.equal(1, fetchCalls.length, "One fetch call made"); + Assert.notEqual(undefined, fetchCalls[0].init, "Has init param"); + + // Get headers - handle both Headers object and plain object cases + let fetchHeaders = fetchCalls[0].init.headers; + let hasHeader = (name: string) => { + if (fetchHeaders instanceof Headers) { + return fetchHeaders.has(name); + } else if (typeof fetchHeaders === 'object') { + return !!fetchHeaders[name]; + } + return false; + }; + + // Check traceparent is present but no AI and no tracestate (no value to send) + Assert.equal(false, hasHeader(RequestHeaders.requestIdHeader), "AI header should not be present"); + Assert.equal(true, hasHeader(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(false, hasHeader(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present when no value"); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + + resolve(); + } catch (e) { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(e); + } + }).catch((err) => { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(new Error("fetch failed! " + (err ? err.toString() : ""))); + }); + } catch (ex) { + reject(ex); + } + }); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: Fetch with AI_AND_W3C mode does not include tracestate header", + test: () => { + return createPromise((resolve, reject) => { + try { + // Setup mock fetch using framework helper + const fetchCalls = this.hookFetch((resolveResponse) => { + setTimeout(() => { + resolveResponse({ + headers: new Headers(), + ok: true, + body: null, + bodyUsed: false, + redirected: false, + status: 200, + statusText: "Hello", + trailer: null, + type: "basic", + url: "https://httpbin.org/status/200" + } as any); + }, 0); + }); + + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + disableFetchTracking: false, + disableAjaxTracking: false, + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.AI_AND_W3C // No tracestate bit + } + } + }; + appInsightsCore.initialize(coreConfig, []); + let trackSpy = this.sandbox.spy(appInsightsCore, "track"); + + // Use test hook to simulate the correct url location + this._ajax["_currentWindowHost"] = "httpbin.org"; + + // Setup + let headers = new Headers(); + headers.append('My-Header', 'Header field'); + let init = { + method: 'get', + headers: headers + }; + const url = 'https://httpbin.org/status/200'; + + // Act + Assert.ok(trackSpy.notCalled, "No fetch called yet"); + fetch(url, init).then(() => { + try { + // Assert + Assert.ok(trackSpy.called, "The request was tracked"); + Assert.equal(1, fetchCalls.length); + Assert.notEqual(undefined, fetchCalls[0].init, "Has init param"); + + // Get headers - handle both Headers object and plain object cases + let fetchHeaders = fetchCalls[0].init.headers; + let hasHeader = (name: string) => { + if (fetchHeaders instanceof Headers) { + return fetchHeaders.has(name); + } else if (typeof fetchHeaders === 'object') { + return !!fetchHeaders[name]; + } + return false; + }; + + // Check that AI and traceparent headers are present but not tracestate + Assert.equal(true, hasHeader(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(true, hasHeader(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(false, hasHeader(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present"); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + + resolve(); + } catch (e) { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(e); + } + }).catch((err) => { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(new Error("fetch failed! " + (err ? err.toString() : ""))); + }); + } catch (ex) { + reject(ex); + } + }); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: Fetch with AI mode does not include W3C headers", + test: () => { + return createPromise((resolve, reject) => { + try { + // Setup mock fetch using framework helper + const fetchCalls = this.hookFetch((resolveResponse) => { + setTimeout(() => { + resolveResponse({ + headers: new Headers(), + ok: true, + body: null, + bodyUsed: false, + redirected: false, + status: 200, + statusText: "Hello", + trailer: null, + type: "basic", + url: "https://httpbin.org/status/200" + } as any); + }, 0); + }); + + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + disableFetchTracking: false, + disableAjaxTracking: false, + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.AI // AI mode only + } + } + }; + appInsightsCore.initialize(coreConfig, []); + let trackSpy = this.sandbox.spy(appInsightsCore, "track"); + + // Use test hook to simulate the correct url location + this._ajax["_currentWindowHost"] = "httpbin.org"; + + // Setup + let headers = new Headers(); + headers.append('My-Header', 'Header field'); + let init = { + method: 'get', + headers: headers + }; + const url = 'https://httpbin.org/status/200'; + + // Act + Assert.ok(trackSpy.notCalled, "No fetch called yet"); + fetch(url, init).then(() => { + try { + // Assert + Assert.ok(trackSpy.called, "The request was tracked"); + Assert.equal(1, fetchCalls.length); + Assert.notEqual(undefined, fetchCalls[0].init, "Has init param"); + + // Get headers - handle both Headers object and plain object cases + let fetchHeaders = fetchCalls[0].init.headers; + let hasHeader = (name: string) => { + if (fetchHeaders instanceof Headers) { + return fetchHeaders.has(name); + } else if (typeof fetchHeaders === 'object') { + return !!fetchHeaders[name]; + } + return false; + }; + + // Check that only AI header is present, no W3C headers + Assert.equal(true, hasHeader(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(false, hasHeader(RequestHeaders.traceParentHeader), "W3c traceparent header should not be present"); + Assert.equal(false, hasHeader(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present"); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + + resolve(); + } catch (e) { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(e); + } + }).catch((err) => { + // Unload the core to clean up hooks even on failure + _safeUnloadCore(appInsightsCore); + reject(new Error("fetch failed! " + (err ? err.toString() : ""))); + }); + } catch (ex) { + reject(ex); + } + }); + } + }); + + // Additional XMLHttpRequest tests with value presence testing + this.testCase({ + name: "W3CTraceStateDependency: tracestate header is only included when there's a value", + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + distributedTracingMode: eDistributedTracingModes.W3C_TRACE + } + } + }; + + appInsightsCore.initialize(coreConfig, []); + + // Store the core instance for cleanup + this._context.core = appInsightsCore; + + // Explicitly set a tracestate value to ensure the header is included + _ensureTraceStateValue(appInsightsCore); + + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act - with manually set tracestate value, there should be a tracestate header + var xhr = new XMLHttpRequest(); + var spy = this.sandbox.spy(xhr, "setRequestHeader"); + xhr.open("GET", "http://www.example.com"); + xhr.send(); + + // Assert that W3C headers are sent, including tracestate (because there's a value) + Assert.equal(false, spy.calledWith(RequestHeaders.requestIdHeader), "AI header should not be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should be present with value"); + + // Emulate response + (xhr as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Create a new monitor without tracestate value + this._ajax.teardown(); + this._ajax = new AjaxMonitor(); + let coreConfig2 = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + // No tracestate value initially + distributedTracingMode: eDistributedTracingModes.W3C_TRACE + } + } + }; + + let appInsightsCore2 = new AppInsightsCore(); + appInsightsCore2.initialize(coreConfig2, [new TestChannelPlugin()]); + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act - without tracestate value, there should be no tracestate header + var xhr2 = new XMLHttpRequest(); + var spy2 = this.sandbox.spy(xhr2, "setRequestHeader"); + xhr2.open("GET", "http://www.example.com"); + xhr2.send(); + + // Assert that even with W3C_TRACE mode, tracestate is not sent (no value to send) + Assert.equal(false, spy2.calledWith(RequestHeaders.requestIdHeader), "AI header should not be present"); + Assert.equal(true, spy2.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(false, spy2.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present when no value"); + + // Emulate response + (xhr2 as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload both core instances to clean up hooks + _safeUnloadCore(appInsightsCore); + _safeUnloadCore(appInsightsCore2); + } + }); + + this.testCase({ + name: "W3CTraceStateDependency: AI_AND_W3C_TRACE mode without tracestate value", + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig = { + instrumentationKey: "instrumentationKey", + extensions: [this._ajax], + channels: [[new TestChannelPlugin()]], + extensionConfig: { + [this._ajax.identifier]: { + // No tracestate value initially + distributedTracingMode: eDistributedTracingModes.AI_AND_W3C_TRACE + } + } + }; + appInsightsCore.initialize(coreConfig, []); + + // Store the core instance for cleanup + this._context.core = appInsightsCore; + + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Act - test initially without any tracestate value + var xhr = new XMLHttpRequest(); + var spy = this.sandbox.spy(xhr, "setRequestHeader"); + xhr.open("GET", "http://www.example.com"); + xhr.send(); + + // Assert that AI and traceparent headers are sent, but not tracestate (no value to send) + Assert.equal(true, spy.calledWith(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(true, spy.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(false, spy.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should not be present without value"); + + // Emulate response + (xhr as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Manually add a tracestate value, which should cause the header to be included + const traceCtx = appInsightsCore.getTraceCtx(); + if (traceCtx && traceCtx.traceState) { + traceCtx.traceState.set("test", "value"); + } + + // Act - now with a manually added tracestate value + var xhr2 = new XMLHttpRequest(); + var spy2 = this.sandbox.spy(xhr2, "setRequestHeader"); + xhr2.open("GET", "http://www.example.com"); + xhr2.send(); + + // Assert that all headers are sent (tracestate included because we manually added a value) + Assert.equal(true, spy2.calledWith(RequestHeaders.requestIdHeader), "AI header should be present"); + Assert.equal(true, spy2.calledWith(RequestHeaders.traceParentHeader), "W3c traceparent header should be present"); + Assert.equal(true, spy2.calledWith(RequestHeaders.traceStateHeader), "W3c tracestate header should be present with manual value"); + + // Emulate response so perf monitoring is cleaned up + (xhr2 as any).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); + + // Unload the core to clean up hooks + _safeUnloadCore(appInsightsCore); + } + }); + } +} 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 6d61b8455..bbdbcc285 100644 --- a/extensions/applicationinsights-dependencies-js/Tests/Unit/src/ajax.tests.ts +++ b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/ajax.tests.ts @@ -1,4 +1,4 @@ -import { SinonStub } from "sinon"; +import { SinonStub } from "sinon"; import { Assert, AITestClass, PollingAssert } from "@microsoft/ai-test-framework"; import { createAsyncResolvedPromise, createSyncPromise } from "@nevware21/ts-async"; import { AjaxMonitor } from "../../../src/ajax"; @@ -9,8 +9,9 @@ import { ActiveStatus } from "@microsoft/applicationinsights-core-js"; import { IDependencyListenerDetails } from "../../../src/DependencyListener"; +import { TestChannelPlugin } from "./TestChannelPlugin"; import { FakeXMLHttpRequest } from "@microsoft/ai-test-framework"; -import { setBypassLazyCache, strLeft } from "@nevware21/ts-utils"; +import { dumpObj, isNullOrUndefined, setBypassLazyCache, strLeft } from "@nevware21/ts-utils"; const AJAX_DATA_CONTAINER = "_ajaxData"; @@ -651,6 +652,8 @@ export class AjaxTests extends AITestClass { let traceCtx = appInsightsCore.getTraceCtx(); let expectedTraceId = generateW3CId(); let expectedSpanId = generateW3CId().substring(0, 16); + + // Note: Replaces the global current traceId and spanId with new values traceCtx!.setTraceId(expectedTraceId); traceCtx!.setSpanId(expectedSpanId); @@ -663,6 +666,7 @@ export class AjaxTests extends AITestClass { let newExpectedTraceId = generateW3CId(); let newExpectedSpanId = generateW3CId().substring(0, 16); + // Note: Replaces the global current traceId and spanId with new values traceCtx!.setTraceId(newExpectedTraceId); traceCtx!.setSpanId(newExpectedSpanId); @@ -680,10 +684,126 @@ export class AjaxTests extends AITestClass { Assert.equal(2, dependencyFields.length, "trackDependencyDataInternal was called again"); Assert.equal(expectedTraceId, dependencyFields[0].sysProperties!.trace.traceID, "Check first traceId"); - Assert.equal(newExpectedTraceId, dependencyFields[1].sysProperties!.trace.traceID, "Check first traceId"); + Assert.equal(newExpectedTraceId, dependencyFields[1].sysProperties!.trace.traceID, "Check second traceId"); Assert.equal(expectedSpanId, dependencyFields[0].sysProperties!.trace.parentID, "Check first spanId"); - Assert.equal(newExpectedSpanId, dependencyFields[1].sysProperties!.trace.parentID, "Check first spanId"); + Assert.equal(newExpectedSpanId, dependencyFields[1].sysProperties!.trace.parentID, "Check second spanId"); + } + }); + + this.testCase({ + name: "Ajax: should create unique spanId for traceparent header without modifying current trace context", + useFakeServer: true, + test: () => { + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig: IConfiguration & IConfig = { + instrumentationKey: "instrumentationKey", + disableAjaxTracking: false, + extensionConfig: { + "AjaxDependencyPlugin": { + distributedTracingMode: DistributedTracingModes.AI_AND_W3C, + enableRequestHeaderTracking: true + } + } + }; + appInsightsCore.initialize(coreConfig, [this._ajax, new TestChannelPlugin()]); + + // Use test hook to simulate the correct url location host to enable correlation headers + this._ajax["_currentWindowHost"] = "www.example.com"; + + // Set up trace context with known values + let traceCtx = appInsightsCore.getTraceCtx(); + let originalTraceId = generateW3CId(); + let originalSpanId = generateW3CId().substring(0, 16); + traceCtx!.setTraceId(originalTraceId); + traceCtx!.setSpanId(originalSpanId); + + // Verify initial state + Assert.equal(originalTraceId, traceCtx!.getTraceId(), "Initial traceId should be set"); + Assert.equal(originalSpanId, traceCtx!.getSpanId(), "Initial spanId should be set"); + + // Act - make first AJAX request + var xhr1 = new XMLHttpRequest(); + var spy1 = this.sandbox.spy(xhr1, "setRequestHeader"); + xhr1.open("GET", "http://www.example.com/api1"); + xhr1.send(); + + // Verify trace context is unchanged after first request + Assert.equal(originalTraceId, traceCtx!.getTraceId(), "TraceId should remain unchanged after first request"); + Assert.equal(originalSpanId, traceCtx!.getSpanId(), "SpanId should remain unchanged after first request"); + + // Extract headers from first request + let firstRequestHeaders: { [key: string]: string } = {}; + spy1.getCalls().forEach(call => { + firstRequestHeaders[call.args[0]] = call.args[1]; + }); + + // Act - make second AJAX request + var xhr2 = new XMLHttpRequest(); + var spy2 = this.sandbox.spy(xhr2, "setRequestHeader"); + xhr2.open("GET", "http://www.example.com/api2"); + xhr2.send(); + + // Verify trace context is still unchanged after second request + Assert.equal(originalTraceId, traceCtx!.getTraceId(), "TraceId should remain unchanged after second request"); + Assert.equal(originalSpanId, traceCtx!.getSpanId(), "SpanId should remain unchanged after second request"); + + // Extract headers from second request + let secondRequestHeaders: { [key: string]: string } = {}; + spy2.getCalls().forEach(call => { + secondRequestHeaders[call.args[0]] = call.args[1]; + }); + + // Validate that both requests have traceparent headers + Assert.ok(firstRequestHeaders[RequestHeaders.traceParentHeader], "First request should have traceparent header"); + Assert.ok(secondRequestHeaders[RequestHeaders.traceParentHeader], "Second request should have traceparent header"); + + // Parse traceparent headers (format: 00-{traceId}-{spanId}-{flags}) + let firstTraceParent = firstRequestHeaders[RequestHeaders.traceParentHeader]; + let secondTraceParent = secondRequestHeaders[RequestHeaders.traceParentHeader]; + + let firstTraceParentParts = firstTraceParent.split('-'); + let secondTraceParentParts = secondTraceParent.split('-'); + + Assert.equal(4, firstTraceParentParts.length, "First traceparent should have 4 parts"); + Assert.equal(4, secondTraceParentParts.length, "Second traceparent should have 4 parts"); + + let firstHeaderTraceId = firstTraceParentParts[1]; + let firstHeaderSpanId = firstTraceParentParts[2]; + let secondHeaderTraceId = secondTraceParentParts[1]; + let secondHeaderSpanId = secondTraceParentParts[2]; + + // Validate traceId consistency - all should use the same traceId + Assert.equal(originalTraceId, firstHeaderTraceId, "First request traceId in header should match original"); + Assert.equal(originalTraceId, secondHeaderTraceId, "Second request traceId in header should match original"); + + // Validate spanId isolation - each request should have unique spanId, different from current context + Assert.notEqual(originalSpanId, firstHeaderSpanId, "First request spanId in header should be different from current context spanId"); + Assert.notEqual(originalSpanId, secondHeaderSpanId, "Second request spanId in header should be different from current context spanId"); + Assert.notEqual(firstHeaderSpanId, secondHeaderSpanId, "Each request should have unique spanId"); + + // Validate spanId format (should be 16 hex characters) + Assert.equal(16, firstHeaderSpanId.length, "First request spanId should be 16 characters"); + Assert.equal(16, secondHeaderSpanId.length, "Second request spanId should be 16 characters"); + Assert.ok(/^[0-9a-f]{16}$/.test(firstHeaderSpanId), "First request spanId should be valid hex"); + Assert.ok(/^[0-9a-f]{16}$/.test(secondHeaderSpanId), "Second request spanId should be valid hex"); + + // Validate AI Request-Id header format consistency + let firstRequestId = firstRequestHeaders[RequestHeaders.requestIdHeader]; + let secondRequestId = secondRequestHeaders[RequestHeaders.requestIdHeader]; + + Assert.ok(firstRequestId?.startsWith("|" + originalTraceId + "."), "First AI Request-Id should start with correct traceId"); + Assert.ok(secondRequestId?.startsWith("|" + originalTraceId + "."), "Second AI Request-Id should start with correct traceId"); + Assert.notEqual(firstRequestId, secondRequestId, "Each request should have unique AI Request-Id"); + + // Clean up responses + (xhr1).respond(200, {}, ""); + (xhr2).respond(200, {}, ""); + + // Final verification that trace context is still unchanged + Assert.equal(originalTraceId, traceCtx!.getTraceId(), "TraceId should remain unchanged at end"); + Assert.equal(originalSpanId, traceCtx!.getSpanId(), "SpanId should remain unchanged at end"); } }); @@ -1581,6 +1701,18 @@ export class AjaxTests extends AITestClass { appInsightsCore.initialize(coreConfig, [this._ajax, new TestChannelPlugin()]); let fetchSpy = this.sandbox.spy(appInsightsCore, "track") let throwSpy = this.sandbox.spy(appInsightsCore.logger, "throwInternal"); + let traceCtx = appInsightsCore.getTraceCtx(); + + let expectedsysProperties = { + trace: { + traceID: traceCtx!.getTraceId(), + parentID: traceCtx!.getSpanId() + } as any + }; + + if (!isNullOrUndefined(traceCtx!.getTraceFlags())) { + expectedsysProperties.trace.traceFlags = traceCtx!.getTraceFlags(); + } // Act Assert.ok(fetchSpy.notCalled, "No fetch called yet"); @@ -1592,7 +1724,7 @@ export class AjaxTests extends AITestClass { Assert.equal(false, throwSpy.called, "We should not have failed internally"); Assert.equal(1, dependencyFields.length, "trackDependencyDataInternal was called"); Assert.ok(dependencyFields[0].dependency.startTime, "startTime was specified before trackDependencyDataInternal was called"); - Assert.equal(undefined, dependencyFields[0].sysProperties, "no system properties"); + Assert.deepEqual(expectedsysProperties, dependencyFields[0].sysProperties, "system properties - " + dumpObj(expectedsysProperties)); fetch(undefined, null).then(() => { // Assert @@ -1600,7 +1732,7 @@ export class AjaxTests extends AITestClass { Assert.equal(false, throwSpy.called, "We should still not have failed internally"); Assert.equal(2, dependencyFields.length, "trackDependencyDataInternal was called"); Assert.ok(dependencyFields[1].dependency.startTime, "startTime was specified before trackDependencyDataInternal was called"); - Assert.equal(undefined, dependencyFields[1].sysProperties, "no system properties"); + Assert.deepEqual(expectedsysProperties, dependencyFields[1].sysProperties, "system properties - " + dumpObj(expectedsysProperties)); testContext.testDone(); }, () => { Assert.ok(false, "fetch failed!"); @@ -1712,6 +1844,18 @@ export class AjaxTests extends AITestClass { appInsightsCore.initialize(coreConfig, [this._ajax, new TestChannelPlugin()]); let fetchSpy = this.sandbox.spy(appInsightsCore, "track") let throwSpy = this.sandbox.spy(appInsightsCore.logger, "throwInternal"); + let traceCtx = appInsightsCore.getTraceCtx(); + + let expectedsysProperties = { + trace: { + traceID: traceCtx.getTraceId(), + parentID: traceCtx.getSpanId() + } as any + }; + + if (!isNullOrUndefined(traceCtx.getTraceFlags())) { + expectedsysProperties.trace.traceFlags = traceCtx.getTraceFlags(); + } // Act Assert.ok(fetchSpy.notCalled, "No fetch called yet"); @@ -1723,7 +1867,7 @@ export class AjaxTests extends AITestClass { Assert.equal(false, throwSpy.called, "We should not have failed internally"); Assert.equal(1, dependencyFields.length, "trackDependencyDataInternal was called"); Assert.ok(dependencyFields[0].dependency.startTime, "startTime was specified before trackDependencyDataInternal was called"); - Assert.equal(undefined, dependencyFields[0].sysProperties, "no system properties"); + Assert.deepEqual(expectedsysProperties, dependencyFields[0].sysProperties, "system properties - " + dumpObj(dependencyFields[0].sysProperties)); Assert.equal(window.location.href.split("#")[0], dependencyFields[0].dependency.target, "Target is captured."); // Assert that the HTTP method was preserved @@ -2439,11 +2583,12 @@ export class AjaxTests extends AITestClass { Assert.equal(true, headers.has(RequestHeaders.requestContextHeader), "requestContext header shoud be present"); Assert.equal(true, headers.has(RequestHeaders.requestIdHeader), "AI header shoud be present"); // AI Assert.equal(true, headers.has(RequestHeaders.traceParentHeader), "W3c header should be present"); // W3C + Assert.equal(false, headers.has(RequestHeaders.traceStateHeader), "traceState should not be present in outbound event"); Assert.notEqual(undefined, trackHeaders[RequestHeaders.requestIdHeader], "RequestId present in outbound event"); Assert.notEqual(undefined, trackHeaders[RequestHeaders.requestContextHeader], "RequestContext present in outbound event"); Assert.notEqual(undefined, trackHeaders[RequestHeaders.traceParentHeader], "traceParent present in outbound event"); - + Assert.equal(undefined, trackHeaders[RequestHeaders.traceStateHeader], "traceState should not be present in outbound event"); } return true; @@ -2454,6 +2599,146 @@ export class AjaxTests extends AITestClass { }, 'response received', 60, 1000) as any) }) + this.testCase({ + name: "Fetch: should create unique spanId for traceparent header without modifying current trace context", + test: () => { + // Setup fetch hook to capture headers + let fetchCalls: any[] = []; + let hookFetch = (resolve) => { + fetchCalls.push = function() { + let result = Array.prototype.push.apply(this, arguments); + AITestClass.orgSetTimeout(function() { + resolve({ + headers: new Headers(), + ok: true, + body: "ab", + bodyUsed: false, + redirected: false, + status: 200, + statusText: "Hello", + trailer: null, + type: "basic", + url: "https://httpbin.org/status/200", + clone: () => null, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + bytes: () => Promise.resolve(new Uint8Array()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve({}), + text: () => Promise.resolve("ab") + } as unknown as Response); + }, 50); + return result; + }; + return fetchCalls; + }; + + this._ajax = new AjaxMonitor(); + let appInsightsCore = new AppInsightsCore(); + let coreConfig: IConfiguration & IConfig = { + instrumentationKey: "instrumentationKey", + disableFetchTracking: false, + extensionConfig: { + "AjaxDependencyPlugin": { + distributedTracingMode: DistributedTracingModes.AI_AND_W3C, + enableRequestHeaderTracking: true + } + } + }; + 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"; + + // Set up trace context with known values + let traceCtx = appInsightsCore.getTraceCtx(); + let originalTraceId = generateW3CId(); + let originalSpanId = generateW3CId().substring(0, 16); + traceCtx!.setTraceId(originalTraceId); + traceCtx!.setSpanId(originalSpanId); + + // Verify initial state + Assert.equal(originalTraceId, traceCtx!.getTraceId(), "Initial traceId should be set"); + Assert.equal(originalSpanId, traceCtx!.getSpanId(), "Initial spanId should be set"); + + // Mock fetch function to capture headers + let originalFetch = window.fetch; + let capturedHeaders: any[] = []; + + window.fetch = function(input: any, init?: any) { + capturedHeaders.push({ + input: input, + init: init, + headers: init ? new Headers(init.headers || {}) : new Headers() + }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({}), + text: () => Promise.resolve("response") + } as Response); + }; + + try { + // Act - make first fetch request (this should trigger header addition) + fetch("https://httpbin.org/status/200", { + method: "GET", + headers: { "Custom-Header": "Value1" } + }); + + // Act - make second fetch request + fetch("https://httpbin.org/api/test", { + method: "POST", + headers: { "Custom-Header": "Value2" } + }); + + // Verify trace context is unchanged + Assert.equal(originalTraceId, traceCtx!.getTraceId(), "TraceId should remain unchanged"); + Assert.equal(originalSpanId, traceCtx!.getSpanId(), "SpanId should remain unchanged"); + + // We should have 2 captured fetch calls + Assert.equal(2, capturedHeaders.length, "Should have captured 2 fetch calls"); + + if (capturedHeaders.length >= 2) { + let firstHeaders = capturedHeaders[0].headers; + let secondHeaders = capturedHeaders[1].headers; + + // Both should have traceparent headers if correlation is enabled + if (firstHeaders.has(RequestHeaders.traceParentHeader) && secondHeaders.has(RequestHeaders.traceParentHeader)) { + let firstTraceParent = firstHeaders.get(RequestHeaders.traceParentHeader); + let secondTraceParent = secondHeaders.get(RequestHeaders.traceParentHeader); + + let firstParts = firstTraceParent.split('-'); + let secondParts = secondTraceParent.split('-'); + + if (firstParts.length === 4 && secondParts.length === 4) { + let firstSpanId = firstParts[2]; + let secondSpanId = secondParts[2]; + + // Validate spanId isolation + Assert.notEqual(originalSpanId, firstSpanId, "First fetch spanId should differ from context"); + Assert.notEqual(originalSpanId, secondSpanId, "Second fetch spanId should differ from context"); + Assert.notEqual(firstSpanId, secondSpanId, "Each fetch should have unique spanId"); + + // Validate traceId consistency + Assert.equal(originalTraceId, firstParts[1], "First fetch should use same traceId"); + Assert.equal(originalTraceId, secondParts[1], "Second fetch should use same traceId"); + } + } + } + + } finally { + // Restore original fetch + window.fetch = originalFetch; + } + + // Final verification + Assert.equal(originalTraceId, traceCtx!.getTraceId(), "TraceId should remain unchanged at end"); + Assert.equal(originalSpanId, traceCtx!.getSpanId(), "SpanId should remain unchanged at end"); + } + }) + this.testCase({ name: "Ajax: successful request, ajax monitor doesn't change payload", test: () => { @@ -3074,7 +3359,7 @@ export class AjaxTests extends AITestClass { // Assert that the W3C header is included Assert.equal(true, spy.calledWith(RequestHeaders.traceParentHeader, expectedTraceParent)); // W3C - Assert.equal(expectedTraceParent, (xhr as FakeXMLHttpRequest).requestHeaders[RequestHeaders.traceParentHeader], "Validate the actual header sent"); + Assert.equal(expectedTraceParent, (xhr as FakeXMLHttpRequest).requestHeaders[RequestHeaders.traceParentHeader], "Validate the actual header sent - actual: [" + (xhr as FakeXMLHttpRequest).requestHeaders[RequestHeaders.traceParentHeader] + "], expected parent [" + expectedTraceParent + "]"); // Emulate response (xhr).respond(200, {"Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*"}, ""); @@ -3927,54 +4212,5 @@ export class AjaxFrozenTests extends AITestClass { } } -class TestChannelPlugin implements IChannelControls { - public isFlushInvoked = false; - public isUnloadInvoked = false; - public isTearDownInvoked = false; - public isResumeInvoked = false; - public isPauseInvoked = false; - constructor() { - this.processTelemetry = this._processTelemetry.bind(this); - } - public pause(): void { - this.isPauseInvoked = true; - } - - public resume(): void { - this.isResumeInvoked = true; - } - - public teardown(): void { - this.isTearDownInvoked = true; - } - - flush(async?: boolean, callBack?: () => void): void { - this.isFlushInvoked = true; - if (callBack) { - callBack(); - } - } - - public processTelemetry; - - public identifier = "Sender"; - - setNextPlugin(next: ITelemetryPlugin) { - // no next setup - } - - public priority: number = 1001; - - public initialize = (config: IConfiguration) => { - } - - private _processTelemetry(env: ITelemetryItem) { - - } -} - -class TestAjaxMonitor extends AjaxMonitor { - -} diff --git a/extensions/applicationinsights-dependencies-js/Tests/Unit/src/dependencies.tests.ts b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/dependencies.tests.ts index 386bbfb16..8bc5d41f5 100644 --- a/extensions/applicationinsights-dependencies-js/Tests/Unit/src/dependencies.tests.ts +++ b/extensions/applicationinsights-dependencies-js/Tests/Unit/src/dependencies.tests.ts @@ -1,9 +1,11 @@ import { AjaxTests, AjaxPerfTrackTests, AjaxFrozenTests } from "./ajax.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; +import { W3CTraceStateDependencyTests } from "./W3CTraceStateDependency.tests"; export function runTests() { new GlobalTestHooks().registerTests(); new AjaxTests().registerTests(); new AjaxPerfTrackTests().registerTests(); new AjaxFrozenTests().registerTests(); + new W3CTraceStateDependencyTests().registerTests(); } \ No newline at end of file diff --git a/extensions/applicationinsights-dependencies-js/src/DependencyListener.ts b/extensions/applicationinsights-dependencies-js/src/DependencyListener.ts index 6f648a081..da768c559 100644 --- a/extensions/applicationinsights-dependencies-js/src/DependencyListener.ts +++ b/extensions/applicationinsights-dependencies-js/src/DependencyListener.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { IAppInsightsCore } from "@microsoft/applicationinsights-core-js"; +import { IAppInsightsCore, IW3cTraceState } from "@microsoft/applicationinsights-core-js"; export interface IDependencyListenerDetails { /** @@ -47,6 +47,13 @@ export interface IDependencyListenerDetails { */ traceFlags?: number; + /** + * The W3C TraceState object that contains the trace state information, this is mutable and changes made to this + * instance will be reflected in the distributed trace context. You cannot overwrite the traceState, but you can + * modify the values within the traceState. + */ + readonly traceState?: IW3cTraceState; + /** * [Optional] Context that the application can assign that will also be passed to any dependency initializer */ diff --git a/extensions/applicationinsights-dependencies-js/src/InternalConstants.ts b/extensions/applicationinsights-dependencies-js/src/InternalConstants.ts index 8ab77843a..d36a8d0fc 100644 --- a/extensions/applicationinsights-dependencies-js/src/InternalConstants.ts +++ b/extensions/applicationinsights-dependencies-js/src/InternalConstants.ts @@ -8,6 +8,7 @@ // 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_DURATION = "duration"; export const STR_PROPERTIES = "properties"; diff --git a/extensions/applicationinsights-dependencies-js/src/ajax.ts b/extensions/applicationinsights-dependencies-js/src/ajax.ts index dd17bba8d..a87838fac 100644 --- a/extensions/applicationinsights-dependencies-js/src/ajax.ts +++ b/extensions/applicationinsights-dependencies-js/src/ajax.ts @@ -11,16 +11,17 @@ import { import { BaseTelemetryPlugin, IAppInsightsCore, IConfigDefaults, IConfiguration, ICustomProperties, IDistributedTraceContext, IInstrumentCallDetails, IInstrumentHooksCallbacks, IPlugin, IProcessTelemetryContext, ITelemetryItem, ITelemetryPluginChain, - InstrumentFunc, InstrumentProto, _eInternalMessageId, _throwInternal, arrForEach, createProcessTelemetryContext, createUniqueNamespace, - dumpObj, eLoggingSeverity, eventOn, fieldRedaction, generateW3CId, getExceptionName, getGlobal, getIEVersion, getLocation, - getPerformance, isFunction, isNullOrUndefined, isString, isXhrSupported, mergeEvtNamespace, onConfigChange, strPrototype, strTrim + InstrumentFunc, InstrumentProto, _eInternalMessageId, _throwInternal, arrForEach, createDistributedTraceContext, + createProcessTelemetryContext, createUniqueNamespace, dumpObj, eLoggingSeverity, eW3CTraceFlags, eventOn, fieldRedaction, generateW3CId, + getExceptionName, getGlobal, getIEVersion, getLocation, getPerformance, isFunction, isNullOrUndefined, isString, isXhrSupported, + mergeEvtNamespace, onConfigChange, strPrototype, strTrim } from "@microsoft/applicationinsights-core-js"; -import { isWebWorker, objFreeze, scheduleTimeout, strIndexOf, strSplit, strSubstr } from "@nevware21/ts-utils"; +import { isWebWorker, objDefineProps, objFreeze, scheduleTimeout, strIndexOf, strSplit, strSubstr } from "@nevware21/ts-utils"; import { DependencyInitializerFunction, IDependencyInitializerDetails, IDependencyInitializerHandler } from "./DependencyInitializer"; import { DependencyListenerFunction, IDependencyHandler, IDependencyListenerContainer, IDependencyListenerDetails, IDependencyListenerHandler } from "./DependencyListener"; -import { IAjaxRecordResponse, ajaxRecord } from "./ajaxRecord"; +import { IAjaxRecordResponse, IXHRMonitoringState, createAjaxRecord } from "./ajaxRecord"; // const AJAX_MONITOR_PREFIX = "ai.ajxmn."; const strDiagLog = "diagLog"; @@ -84,11 +85,11 @@ function _supportsAjaxMonitoring(ajaxMonitorInstance: AjaxMonitor, ajaxDataId: s let xhrData: XMLHttpRequestData = { xh: [], i: { - [ajaxDataId]: {} as ajaxRecord + [ajaxDataId]: {} as IAjaxRecordInternal } }; - xhr[AJAX_DATA_CONTAINER] = xhrData; + (xhr as any)[AJAX_DATA_CONTAINER] = xhrData; // Check that we can update the prototype let theOpen = XMLHttpRequest[strPrototype].open; @@ -114,7 +115,7 @@ function _supportsAjaxMonitoring(ajaxMonitorInstance: AjaxMonitor, ajaxDataId: s * @param ajaxDataId * @returns */ -const _getAjaxData = (xhr: XMLHttpRequestInstrumented, ajaxDataId: string): ajaxRecord => { +const _getAjaxData = (xhr: XMLHttpRequestInstrumented, ajaxDataId: string): IAjaxRecordInternal => { if (xhr && ajaxDataId && xhr[AJAX_DATA_CONTAINER]) { return (xhr[AJAX_DATA_CONTAINER].i || { })[ajaxDataId]; } @@ -244,28 +245,50 @@ function _processDependencyContainer(core: IAppInsightsCo return result; } -function _processDependencyListeners(listeners: _IInternalDependencyHandler[], core: IAppInsightsCore, ajaxData: ajaxRecord, xhr: XMLHttpRequest, input?: Request | string, init?: RequestInit): boolean { +function _processDependencyListeners(listeners: _IInternalDependencyHandler[], core: IAppInsightsCore, ajaxData: IAjaxRecordInternal, xhr: XMLHttpRequest, input?: Request | string, init?: RequestInit): boolean { var initializersCount = listeners.length; let result = true; if (initializersCount > 0) { + let traceCtx = ajaxData.traceCtx; let details: IDependencyListenerDetails = { core: core, xhr: xhr, input: input, init: init, - traceId: ajaxData.traceID, - spanId: ajaxData.spanID, - traceFlags: ajaxData.traceFlags, - context: ajaxData.context || {}, aborted: !!ajaxData.aborted }; + + objDefineProps(details, { + "traceId": { + g: () => traceCtx.traceId, + s: (value) => { + traceCtx.traceId = value; + } + }, + "spanId": { + g: () => traceCtx.spanId, + s: (value) => { + traceCtx.spanId = value; + } + }, + "traceFlags": { + g: () => traceCtx.traceFlags, + s: (value) => { + traceCtx.traceFlags = value; + } + }, + "traceState": { + g: () => traceCtx.traceState + }, + "context": { + g: () => ajaxData.context || {}, + s: (value) => { + ajaxData.context = value; + } + } + }); result = _processDependencyContainer(core, listeners, details, "listener"); - - ajaxData.traceID = details.traceId; - ajaxData.spanID = details.spanId; - ajaxData.traceFlags = details.traceFlags; - ajaxData.context = details.context; } return result; @@ -280,7 +303,7 @@ export interface XMLHttpRequestData { /** * The individual tracking data for each AI instance */ - i: { [key: string]: ajaxRecord }; + i: { [key: string]: IAjaxRecordInternal }; } export interface XMLHttpRequestInstrumented extends XMLHttpRequest { @@ -309,8 +332,181 @@ export interface IDependenciesPlugin extends IDependencyListenerContainer { trackDependencyData(dependency: IDependencyTelemetry): void; } +/** + * Interface for ajax data passed to includeCorrelationHeaders function. + * Contains the public properties and methods needed for correlation header processing. + * + * @public + */ +export interface IAjaxRecordData { + /** + * Gets the absolute URL for the request + * @returns The absolute URL string or null + */ + getAbsoluteUrl(): string | null; + + /** + * Gets the sanitized path name for the request URL + * @returns The sanitized path name string or null + */ + getPathName(): string | null; + + /** + * The distributed trace context for the request containing trace ID, span ID, and trace flags + */ + readonly traceCtx: IDistributedTraceContext; + + /** + * Object containing request headers that have been set for this request + */ + requestHeaders: { [key: string]: string }; + + /** + * Indicates whether the request was aborted (0 = not aborted, 1 = aborted) + */ + aborted: number; + + /** + * Optional context object that can be set by dependency listeners + */ + context?: { [key: string]: any }; +} + +/** + * Internal interface that extends the public IAjaxRecordData with additional properties and methods + * used internally by the AJAX monitoring implementation. + * + * @internal + */ +export interface IAjaxRecordInternal extends IAjaxRecordData { + /** + * Indicates if the ajax call has completed + */ + completed: boolean; + + /** + * Size of the request headers in bytes + */ + requestHeadersSize: number; + + /** + * Duration of receiving the response in milliseconds + */ + responseReceivingDuration: number; + + /** + * Duration of the callback execution in milliseconds + */ + callbackDuration: number; + + /** + * Total duration of the ajax call in milliseconds + */ + ajaxTotalDuration: number; + + /** + * URL of the page that initiated the request + */ + pageUrl: string; + + /** + * The URL of the request + */ + requestUrl: string; + + /** + * Size of the request in bytes + */ + requestSize: number; + + /** + * HTTP method used for the request + */ + method: string; + + /** + * Performance mark associated with this request + */ + perfMark: PerformanceMark; + + /** + * Performance timing data from the Resource Timing API + */ + perfTiming: PerformanceResourceTiming; + + /** + * Number of attempts to find performance data + */ + perfAttempts?: number; + + /** + * Indicates if the request was made asynchronously + */ + async?: boolean; + + /** + * Should the Error Status text be included in the response + */ + errorStatusText?: boolean; + + /** + * HTTP status code of the response + */ + status: string | number; + + /** + * Timestamp when the request was sent + */ + requestSentTime: number; + + /** + * Timestamp when the first byte was received + */ + responseStartedTime: number; + + /** + * Timestamp when the last byte was received + */ + responseFinishedTime: number; + + /** + * Timestamp when the onreadystatechange callback finished + */ + callbackFinishedTime: number; + + /** + * Timestamp when the ajax call ended + */ + endTime: number; + + /** + * State tracking object for XHR monitoring + */ + xhrMonitoringState: IXHRMonitoringState; + + /** + * Indicates if a JavaScript exception occurred in xhr.onreadystatechange code (1 if occurred, 0 otherwise) + */ + clientFailure: number; + + /** + * Creates a telemetry item for tracking this ajax request + * @param ajaxType - Type of the ajax request + * @param enableRequestHeaderTracking - Whether to include request headers in telemetry + * @param getResponse - Function to get response data + * @returns Dependency telemetry item or null + */ + CreateTrackItem(ajaxType: string, enableRequestHeaderTracking: boolean, getResponse: () => IAjaxRecordResponse): IDependencyTelemetry; + + /** + * Gets Part A properties for telemetry + * @returns Object containing Part A properties or null + */ + getPartAProps(): { [key: string]: any }; +} + export interface IInstrumentationRequirements extends IDependenciesPlugin { - includeCorrelationHeaders: (ajaxData: ajaxRecord, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented) => any; + includeCorrelationHeaders: (ajaxData: IAjaxRecordData, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented) => any; } const _defaultConfig: IConfigDefaults = objFreeze({ @@ -359,6 +555,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu let _context: ITelemetryContext; let _isUsingW3CHeaders: boolean; let _isUsingAIHeaders: boolean; + let _isUsingW3CTraceState: boolean; let _markPrefix: string; let _enableAjaxPerfTracking: boolean; let _maxAjaxCallsPerView: number; @@ -407,9 +604,9 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu _reportDependencyInternal(_dependencyInitializers, _self.core, null, dependency, properties); } - _self.includeCorrelationHeaders = (ajaxData: ajaxRecord, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented): any => { + _self.includeCorrelationHeaders = (ajaxData: IAjaxRecordInternal, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented): any => { // Test Hook to allow the overriding of the location host - let currentWindowHost = _self["_currentWindowHost"] || _currentWindowHost; + let currentWindowHost = (_self as any)["_currentWindowHost"] || _currentWindowHost; if (_processDependencyListeners(_dependencyListeners, _self.core, ajaxData, xhr, input, init)) { if (input || input === "") { // Fetch @@ -418,12 +615,14 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu init = {}; } + let traceCtx = ajaxData.traceCtx; + // init headers override original request headers // so, if they exist use only them, otherwise use request's because they should have been applied in the first place // not using original request headers will result in them being lost let headers = new Headers(init.headers || (input instanceof Request ? (input.headers || {}) : {})); if (_isUsingAIHeaders) { - const id = "|" + ajaxData.traceID + "." + ajaxData.spanID; + const id = "|" + traceCtx.traceId + "." + traceCtx.spanId; headers.set(RequestHeaders[eRequestHeaders.requestIdHeader], id); if (_enableRequestHeaderTracking) { ajaxData.requestHeaders[RequestHeaders[eRequestHeaders.requestIdHeader]] = id; @@ -437,27 +636,52 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu } } if (_isUsingW3CHeaders) { - let traceFlags = ajaxData.traceFlags; + let traceFlags = traceCtx.traceFlags; if (isNullOrUndefined(traceFlags)) { - traceFlags = 0x01; + traceFlags = eW3CTraceFlags.Sampled; // Default to sampled } - const traceParent = formatTraceParent(createTraceParent(ajaxData.traceID, ajaxData.spanID, traceFlags)); + const traceParent = formatTraceParent(createTraceParent(traceCtx.traceId, traceCtx.spanId, traceFlags)); headers.set(RequestHeaders[eRequestHeaders.traceParentHeader], traceParent); if (_enableRequestHeaderTracking) { ajaxData.requestHeaders[RequestHeaders[eRequestHeaders.traceParentHeader]] = traceParent; } } + if (_isUsingW3CTraceState) { + if (traceCtx.traceState && !traceCtx.traceState.isEmpty) { + const traceStateHeaders = traceCtx.traceState.hdrs(); + if (traceStateHeaders && traceStateHeaders.length > 0) { + let stateSet = false; + arrForEach(traceStateHeaders, (stateValue) => { + if (stateValue) { + if (!stateSet) { + stateSet = true; + headers.set(RequestHeaders[eRequestHeaders.traceStateHeader], stateValue); + } else { + headers.append(RequestHeaders[eRequestHeaders.traceStateHeader], stateValue); + } + } + }); + } + + if (_enableRequestHeaderTracking) { + ajaxData.requestHeaders[RequestHeaders[eRequestHeaders.traceStateHeader]] = traceStateHeaders.join(","); + } + } + } + init.headers = headers; } return init; } else if (xhr) { // XHR if (correlationIdCanIncludeCorrelationHeader(_extensionConfig, ajaxData.getAbsoluteUrl(), currentWindowHost)) { + let traceCtx = ajaxData.traceCtx; + if (_isUsingAIHeaders) { if (!_isHeaderSet(xhr, RequestHeaders[eRequestHeaders.requestIdHeader])) { - const id = "|" + ajaxData.traceID + "." + ajaxData.spanID; + const id = "|" + traceCtx.traceId + "." + traceCtx.spanId; xhr.setRequestHeader(RequestHeaders[eRequestHeaders.requestIdHeader], id); if (_enableRequestHeaderTracking) { ajaxData.requestHeaders[RequestHeaders[eRequestHeaders.requestIdHeader]] = id; @@ -480,13 +704,13 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu } } if (_isUsingW3CHeaders) { - let traceFlags = ajaxData.traceFlags; + let traceFlags = traceCtx.traceFlags; if (isNullOrUndefined(traceFlags)) { - traceFlags = 0x01; + traceFlags = eW3CTraceFlags.Sampled; } if (!_isHeaderSet(xhr, RequestHeaders[eRequestHeaders.traceParentHeader])) { - const traceParent = formatTraceParent(createTraceParent(ajaxData.traceID, ajaxData.spanID, traceFlags)); + const traceParent = formatTraceParent(createTraceParent(traceCtx.traceId, traceCtx.spanId, traceFlags)); xhr.setRequestHeader(RequestHeaders[eRequestHeaders.traceParentHeader], traceParent); if (_enableRequestHeaderTracking) { ajaxData.requestHeaders[RequestHeaders[eRequestHeaders.traceParentHeader]] = traceParent; @@ -496,6 +720,23 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu "Unable to set [" + RequestHeaders[eRequestHeaders.traceParentHeader] + "] as it has already been set by another instance"); } } + + if (_isUsingW3CTraceState) { + if (traceCtx.traceState && !traceCtx.traceState.isEmpty) { + const traceStateHeaders = traceCtx.traceState.hdrs(); + if (traceStateHeaders && traceStateHeaders.length > 0) { + arrForEach(traceStateHeaders, (stateValue) => { + if (stateValue) { + xhr.setRequestHeader(RequestHeaders[eRequestHeaders.traceStateHeader], stateValue); + } + }); + } + + if (_enableRequestHeaderTracking) { + ajaxData.requestHeaders[RequestHeaders[eRequestHeaders.traceStateHeader]] = traceStateHeaders.join(","); + } + } + } } return xhr; @@ -558,6 +799,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu _trackAjaxAttempts = 0; _context = null; _isUsingW3CHeaders = false; + _isUsingW3CTraceState = false; _isUsingAIHeaders = false; _markPrefix = null; _enableAjaxPerfTracking = false; @@ -596,8 +838,10 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu _excludeRequestFromAutoTrackingPatterns = [].concat(_extensionConfig.excludeRequestFromAutoTrackingPatterns || [], _extensionConfig.addIntEndpoints !== false ? _internalExcludeEndpoints : []); _addRequestContext = _extensionConfig.addRequestContext; - _isUsingAIHeaders = _distributedTracingMode === eDistributedTracingModes.AI || _distributedTracingMode === eDistributedTracingModes.AI_AND_W3C; - _isUsingW3CHeaders = _distributedTracingMode === eDistributedTracingModes.AI_AND_W3C || _distributedTracingMode === eDistributedTracingModes.W3C; + let baseDistributedTracingMode = _distributedTracingMode & eDistributedTracingModes._BaseMask; + _isUsingAIHeaders = baseDistributedTracingMode === eDistributedTracingModes.AI || baseDistributedTracingMode === eDistributedTracingModes.AI_AND_W3C; + _isUsingW3CHeaders = baseDistributedTracingMode === eDistributedTracingModes.AI_AND_W3C || baseDistributedTracingMode === eDistributedTracingModes.W3C; + _isUsingW3CTraceState = !!(_distributedTracingMode & eDistributedTracingModes._W3CTraceState); if (_enableAjaxPerfTracking) { _markPrefix = _ajaxDataId; @@ -650,7 +894,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu ns: _evtNamespace, // Add request hook req: (callDetails: IInstrumentCallDetails, input, init) => { - let fetchData: ajaxRecord; + let fetchData: IAjaxRecordInternal; if (!_disableFetchTracking && _fetchInitialized && !_isDisabledRequest(null, input, init) && // If we have a polyfil and XHR instrumented then let XHR report otherwise we get duplicates @@ -678,7 +922,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu }; if (_enableResponseHeaderTracking && response) { - const responseHeaderMap = {}; + const responseHeaderMap: any = {}; response.headers.forEach((value: string, name: string) => { // @skip-minify if (_canIncludeHeaders(name)) { responseHeaderMap[name] = value; @@ -861,11 +1105,11 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu // check that this instance is not not used by ajax call performed inside client side monitoring to send data to collector if (!isNullOrUndefined(xhr)) { // Look on the XMLHttpRequest of the URL string value - isDisabled = xhr[DisabledPropertyName] === true || theUrl[DisabledPropertyName] === true; + isDisabled = (xhr as any)[DisabledPropertyName] === true || (theUrl as any)[DisabledPropertyName] === true; } else if (!isNullOrUndefined(request)) { // fetch // Look for DisabledPropertyName in either Request or RequestInit - isDisabled = (typeof request === "object" ? request[DisabledPropertyName] === true : false) || - (init ? init[DisabledPropertyName] === true : false); + isDisabled = (typeof request === "object" ? (request as any)[DisabledPropertyName] === true : false) || + (init ? (init as any)[DisabledPropertyName] === true : false); } // Also add extra check just in case the XHR or fetch objects where not decorated with the DisableProperty due to sealing or freezing @@ -891,7 +1135,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu /// Verifies that particular instance of XMLHttpRequest needs to be monitored /// Optional parameter. True if ajaxData must be excluded from verification /// True if instance needs to be monitored, otherwise false - function _isMonitoredXhrInstance(xhr: XMLHttpRequestInstrumented, ajaxData: ajaxRecord, excludeAjaxDataValidation?: boolean): boolean { + function _isMonitoredXhrInstance(xhr: XMLHttpRequestInstrumented, ajaxData: IAjaxRecordInternal, excludeAjaxDataValidation?: boolean): boolean { let ajaxValidation = true; let initialized = _xhrInitialized; if (!isNullOrUndefined(xhr)) { @@ -904,31 +1148,56 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu && ajaxValidation; } - function _getDistributedTraceCtx(): IDistributedTraceContext { + /** + * Using the ajaxRecord (for now) to capture what will be the "span" state for the ajax request. + * @returns + */ + function _startSpan(): IAjaxRecordInternal { let distributedTraceCtx: IDistributedTraceContext = null; if (_self.core && _self.core.getTraceCtx) { - distributedTraceCtx = _self.core.getTraceCtx(false); + // Note creating a new distributed trace context + // This is to ensure that we have a original traceId and spanId for each request + distributedTraceCtx = createDistributedTraceContext(_self.core.getTraceCtx()); } - // Fall back + // Fall back if running on an older version of the core if (!distributedTraceCtx && _context && _context.telemetryTrace) { distributedTraceCtx = createDistributedTraceContextFromTrace(_context.telemetryTrace); } - return distributedTraceCtx; + // TODO(OTelSpan): change to call traceCtx.startSpan() when available rather than setting + // a new spanId and traceId + let newCtx = createDistributedTraceContext(distributedTraceCtx); + // Always generate a new spanId for each dependency request to ensure proper span isolation + newCtx.traceId = newCtx.traceId || generateW3CId(); + newCtx.spanId = strSubstr(generateW3CId(), 0, 16); + + return createAjaxRecord(newCtx, _self[strDiagLog]()); } - function _openHandler(xhr: XMLHttpRequestInstrumented, method: string, url: string, async: boolean): ajaxRecord { - let distributedTraceCtx: IDistributedTraceContext = _getDistributedTraceCtx(); + function _endSpan( + ajaxData: IAjaxRecordInternal, + dependency: IDependencyTelemetry, + properties: { [key: string]: any } | undefined) { - const traceID = (distributedTraceCtx && distributedTraceCtx.getTraceId()) || generateW3CId(); - const spanID = strSubstr(generateW3CId(), 0, 16); + // TODO(OTelSpan): change to call span.end() when available + if (dependency) { + if (properties !== undefined) { + dependency.properties = {...dependency.properties, ...properties}; + } + + let sysProperties = ajaxData.getPartAProps(); + _reportDependencyInternal(_dependencyInitializers, _self.core, ajaxData, dependency, properties, sysProperties); + } + + } + + function _openHandler(xhr: XMLHttpRequestInstrumented, method: string, url: string, async: boolean): IAjaxRecordInternal { let xhrRequestData = xhr[AJAX_DATA_CONTAINER] = (xhr[AJAX_DATA_CONTAINER] || { xh: [], i: {}}); let ajaxDataCntr = xhrRequestData.i = (xhrRequestData.i || { }); - const ajaxData = ajaxDataCntr[_ajaxDataId] = (ajaxDataCntr[_ajaxDataId] || new ajaxRecord(traceID, spanID, _self[strDiagLog](), _self.core?.getTraceCtx())); + const ajaxData = ajaxDataCntr[_ajaxDataId] = (ajaxDataCntr[_ajaxDataId] || _startSpan()); - ajaxData.traceFlags = distributedTraceCtx && distributedTraceCtx.getTraceFlags(); ajaxData.method = method; ajaxData.requestUrl = url; ajaxData.xhrMonitoringState.openDone = true; @@ -939,7 +1208,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu return ajaxData; } - function _attachToOnReadyStateChange(xhr: XMLHttpRequestInstrumented, ajaxData: ajaxRecord) { + function _attachToOnReadyStateChange(xhr: XMLHttpRequestInstrumented, ajaxData: IAjaxRecordInternal) { ajaxData.xhrMonitoringState.stateChangeAttached = eventOn(xhr, "readystatechange", () => { try { if (xhr && xhr.readyState === 4 && _isMonitoredXhrInstance(xhr, ajaxData)) { @@ -982,7 +1251,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu ajaxData.status = xhr.status; function _reportXhrError(e: any, failedProps?:Object) { - let errorProps = failedProps||{}; + let errorProps = failedProps||{} as any; errorProps["ajaxDiagnosticsMessage"] = _getFailedAjaxDiagnosticsMessage(xhr, _ajaxDataId); if (e) { errorProps["exception"] = dumpObj(e); @@ -1013,7 +1282,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu // xhr.getAllResponseHeaders() method returns all the response headers, separated by CRLF, as a string or null // the regex converts the header string into an array of individual headers const arr = strTrim(headers).split(/[\r\n]+/); - const responseHeaderMap = {}; + const responseHeaderMap: any = {}; arrForEach(arr, (line) => { const parts = line.split(": "); const header = parts.shift(); @@ -1041,19 +1310,14 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu CUSTOM_REQUEST_CONTEXT_ERROR); } - if (dependency) { - if (properties !== undefined) { - dependency.properties = {...dependency.properties, ...properties}; - } - - let sysProperties = ajaxData.getPartAProps(); - _reportDependencyInternal(_dependencyInitializers, _self.core, ajaxData, dependency, null, sysProperties); - } else { + if (!dependency) { _reportXhrError(null, { requestSentTime: ajaxData.requestSentTime, responseFinishedTime: ajaxData.responseFinishedTime }); } + + _endSpan(ajaxData, dependency, properties); } finally { // cleanup telemetry data try { @@ -1092,7 +1356,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu } } - function _createMarkId(type:string, ajaxData:ajaxRecord) { + function _createMarkId(type:string, ajaxData:IAjaxRecordInternal) { if (ajaxData.requestUrl && _markPrefix && _enableAjaxPerfTracking) { let performance = getPerformance(); if (performance && isFunction(performance.mark)) { @@ -1107,7 +1371,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu } } - function _findPerfResourceEntry(initiatorType:string, ajaxData:ajaxRecord, trackCallback:() => void, reportError:(e:any) => void): void { + function _findPerfResourceEntry(initiatorType:string, ajaxData:IAjaxRecordInternal, trackCallback:() => void, reportError:(e:any) => void): void { let perfMark = ajaxData.perfMark; let performance = getPerformance(); let maxAttempts = _maxAjaxPerfLookupAttempts; @@ -1169,14 +1433,8 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu })(); } - function _createFetchRecord(input?: Request | string, init?: RequestInit): ajaxRecord { - let distributedTraceCtx: IDistributedTraceContext = _getDistributedTraceCtx(); - - const traceID = (distributedTraceCtx && distributedTraceCtx.getTraceId()) || generateW3CId(); - const spanID = strSubstr(generateW3CId(), 0, 16); - - let ajaxData = new ajaxRecord(traceID, spanID, _self[strDiagLog](), _self.core?.getTraceCtx()); - ajaxData.traceFlags = distributedTraceCtx && distributedTraceCtx.getTraceFlags(); + function _createFetchRecord(input?: Request | string, init?: RequestInit): IAjaxRecordInternal { + let ajaxData = _startSpan(); ajaxData.requestSentTime = dateTimeUtilsNow(); ajaxData.errorStatusText = _enableAjaxErrorStatusText; @@ -1207,7 +1465,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu } ajaxData.method = method; - let requestHeaders = {}; + let requestHeaders: any = {}; if (_enableRequestHeaderTracking) { let headers = new Headers((init ? init.headers : 0) || (input instanceof Request ? (input.headers || {}) : {})); headers.forEach((value, key) => { // @skip-minify @@ -1243,13 +1501,13 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu return result; } - function _reportFetchMetrics(callDetails: IInstrumentCallDetails, status: number, input: Request, response: Response | string, ajaxData: ajaxRecord, getResponse:() => IAjaxRecordResponse, properties?: { [key: string]: any }): void { + function _reportFetchMetrics(callDetails: IInstrumentCallDetails, status: number, input: Request, response: Response | string, ajaxData: IAjaxRecordInternal, getResponse:() => IAjaxRecordResponse, properties?: { [key: string]: any }): void { if (!ajaxData) { return; } function _reportFetchError(msgId: _eInternalMessageId, e: any, failedProps?:Object) { - let errorProps = failedProps||{}; + let errorProps = failedProps||{} as any; errorProps["fetchDiagnosticsMessage"] = _getFailedFetchDiagnosticsMessage(input); if (e) { errorProps["exception"] = dumpObj(e); @@ -1278,20 +1536,15 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu CUSTOM_REQUEST_CONTEXT_ERROR); } - if (dependency) { - if (properties !== undefined) { - dependency.properties = {...dependency.properties, ...properties}; - } - - let sysProperties = ajaxData.getPartAProps(); - _reportDependencyInternal(_dependencyInitializers, _self.core, ajaxData, dependency, null, sysProperties); - } else { + if (!dependency) { _reportFetchError(_eInternalMessageId.FailedMonitorAjaxDur, null, { requestSentTime: ajaxData.requestSentTime, responseFinishedTime: ajaxData.responseFinishedTime }); } + + _endSpan(ajaxData, dependency, properties); }, (e) => { _reportFetchError(_eInternalMessageId.FailedMonitorAjaxGetCorrelationHeader, e, null); }); @@ -1317,7 +1570,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu function _reportDependencyInternal( initializers: _IInternalDependencyHandler[], core: IAppInsightsCore, - ajaxData: ajaxRecord, + ajaxData: IAjaxRecordInternal, dependency: IDependencyTelemetry, properties?: { [key: string]: any }, systemProperties?: { [key: string]: any } @@ -1360,7 +1613,7 @@ export class AjaxMonitor extends BaseTelemetryPlugin implements IDependenciesPlu // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } - public includeCorrelationHeaders(ajaxData: ajaxRecord, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented): any { + public includeCorrelationHeaders(ajaxData: IAjaxRecordData, input?: Request | string, init?: RequestInit, xhr?: XMLHttpRequestInstrumented): any { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } diff --git a/extensions/applicationinsights-dependencies-js/src/ajaxRecord.ts b/extensions/applicationinsights-dependencies-js/src/ajaxRecord.ts index 5725aa744..407948145 100644 --- a/extensions/applicationinsights-dependencies-js/src/ajaxRecord.ts +++ b/extensions/applicationinsights-dependencies-js/src/ajaxRecord.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import dynamicProto from "@microsoft/dynamicproto-js"; +import type { IAjaxRecordInternal } from "./ajax"; import { Extensions, IDependencyTelemetry, dataSanitizeUrl, dateTimeUtilsDuration, msToTimeSpan, urlGetAbsoluteUrl, urlGetCompleteUrl } from "@microsoft/applicationinsights-common"; @@ -11,6 +11,7 @@ import { import { mathRound } from "@nevware21/ts-utils"; import { STR_DURATION, STR_PROPERTIES } from "./InternalConstants"; +// Type-only import to avoid circular dependency export interface IAjaxRecordResponse { statusText: string, headerMap: Object, @@ -20,17 +21,11 @@ export interface IAjaxRecordResponse { response?: Object } -interface ITraceCtx { - traceId: string; - spanId: string; - traceFlags: number; -} - /** @ignore */ function _calcPerfDuration(resourceEntry:PerformanceResourceTiming, start:string, end:string) { let result = 0; - let from = resourceEntry[start]; - let to = resourceEntry[end]; + let from = (resourceEntry as any)[start]; + let to = (resourceEntry as any)[end]; if (from && to) { result = dateTimeUtilsDuration(from, to); } @@ -63,7 +58,7 @@ function _setPerfValue(props:any, name:string, value:any): number { } /** @ignore */ -function _populatePerfData(ajaxData:ajaxRecord, dependency:IDependencyTelemetry) { +function _populatePerfData(ajaxData:IAjaxRecordInternal, dependency:IDependencyTelemetry) { /* * https://developer.mozilla.org/en-US/docs/Web/API/Resource_Timing_API/Using_the_Resource_Timing_API * | -startTime @@ -144,9 +139,9 @@ function _populatePerfData(ajaxData:ajaxRecord, dependency:IDependencyTelemetry) propsSet |= _setPerfValue(props, STR_DURATION, duration); propsSet |= _setPerfValue(props, "perfTotal", duration); - var serverTiming = resourceEntry[strServerTiming]; + var serverTiming = (resourceEntry as any)[strServerTiming]; if (serverTiming) { - let server = {}; + let server: any = {}; arrForEach(serverTiming, (value, idx) => { let name = normalizeJsName(value[strName] || "" + idx); let newValue = server[name] || {}; @@ -166,9 +161,9 @@ function _populatePerfData(ajaxData:ajaxRecord, dependency:IDependencyTelemetry) propsSet |= _setPerfValue(props, strServerTiming, server); } - propsSet |= _setPerfValue(props, strTransferSize, resourceEntry[strTransferSize]); - propsSet |= _setPerfValue(props, strEncodedBodySize, resourceEntry[strEncodedBodySize]); - propsSet |= _setPerfValue(props, strDecodedBodySize, resourceEntry[strDecodedBodySize]); + propsSet |= _setPerfValue(props, strTransferSize, (resourceEntry as any)[strTransferSize]); + propsSet |= _setPerfValue(props, strEncodedBodySize, (resourceEntry as any)[strEncodedBodySize]); + propsSet |= _setPerfValue(props, strDecodedBodySize, (resourceEntry as any)[strDecodedBodySize]); } else { if (ajaxData.perfMark) { propsSet |= _setPerfValue(props, "missing", ajaxData.perfAttempts); @@ -180,258 +175,173 @@ function _populatePerfData(ajaxData:ajaxRecord, dependency:IDependencyTelemetry) } } -export class XHRMonitoringState { - public openDone: boolean; - public setRequestHeaderDone: boolean; - public sendDone: boolean; - public abortDone: boolean; - - // True, if onreadyStateChangeCallback function attached to xhr, otherwise false - public stateChangeAttached: boolean; - - constructor() { - let self = this; - self.openDone = false; - self.setRequestHeaderDone = false; - self.sendDone = false; - self.abortDone = false; +/** + * Interface defining the XHR monitoring state properties + */ +export interface IXHRMonitoringState { + openDone: boolean; + setRequestHeaderDone: boolean; + sendDone: boolean; + abortDone: boolean; - // True, if onreadyStateChangeCallback function attached to xhr, otherwise false - self.stateChangeAttached = false; - } + // True, if onreadyStateChangeCallback function attached to xhr, otherwise false + stateChangeAttached: boolean; } -export class ajaxRecord { - public completed:boolean; - public requestHeadersSize:number; - public requestHeaders:any; - public responseReceivingDuration:number; - public callbackDuration:number; - public ajaxTotalDuration:number; - public aborted:number; - public pageUrl:string; - public requestUrl:string; - public requestSize:number; - public method:string; - public perfMark:PerformanceMark; - public perfTiming:PerformanceResourceTiming; - public perfAttempts?:number; - public async?:boolean; - - /// Should the Error Status text be included in the response - public errorStatusText?:boolean; - - /// Returns the HTTP status code. - public status:string|number; - - // The timestamp when open method was invoked - public requestSentTime: number; - - // The timestamps when first byte was received - public responseStartedTime: number; - - // The timestamp when last byte was received - public responseFinishedTime: number; - - // The timestamp when onreadystatechange callback in readyState 4 finished - public callbackFinishedTime: number; - - // The timestamp at which ajax was ended - public endTime: number; - - public xhrMonitoringState: XHRMonitoringState; - - // Determines whether or not JavaScript exception occurred in xhr.onreadystatechange code. 1 if occurred, otherwise 0. - public clientFailure: number; - - /** - * The traceId to use for the dependency call - */ - public traceID: string; - - /** - * The spanId to use for the dependency call - */ - public spanID: string; - - /** - * The traceFlags to use for the dependency call - */ - public traceFlags?: number; - - /** - * The trace context to use for reporting the remote dependency call - */ - public eventTraceCtx: ITraceCtx; - - /** - * The listener assigned context values that will be passed to any dependency initializer - */ - public context?: { [key: string]: any }; - - constructor(traceId: string, spanId: string, logger: IDiagnosticLogger, traceCtx?: IDistributedTraceContext) { - let self = this; - let _logger: IDiagnosticLogger = logger; - let strResponseText = "responseText"; - - // Assigning the initial/default values within the constructor to avoid typescript from creating a bunch of - // this.XXXX = null - self.perfMark = null; - self.completed = false; - self.requestHeadersSize = null; - self.requestHeaders = null; - self.responseReceivingDuration = null; - self.callbackDuration = null; - self.ajaxTotalDuration = null; - self.aborted = 0; - self.pageUrl = null; - self.requestUrl = null; - self.requestSize = 0; - self.method = null; - self.status = null; - self.requestSentTime = null; - self.responseStartedTime = null; - self.responseFinishedTime = null; - self.callbackFinishedTime = null; - self.endTime = null; - self.xhrMonitoringState = new XHRMonitoringState(); - self.clientFailure = 0; - - self.traceID = traceId; - self.spanID = spanId; - self.traceFlags = traceCtx?.getTraceFlags(); - - if (traceCtx) { - self.eventTraceCtx = { - traceId: traceCtx.getTraceId(), - spanId: traceCtx.getSpanId(), - traceFlags: traceCtx.getTraceFlags() - }; - } else { - self.eventTraceCtx = null; - } +/** + * Factory function to create an XHR monitoring state object + * @returns An object implementing IXHRMonitoringState interface + */ +export function createXHRMonitoringState(): IXHRMonitoringState { + return { + openDone: false, + setRequestHeaderDone: false, + sendDone: false, + abortDone: false, + stateChangeAttached: false + }; +} - dynamicProto(ajaxRecord, self, (self) => { - self.getAbsoluteUrl= () => { - return self.requestUrl ? urlGetAbsoluteUrl(self.requestUrl) : null; +/** + * Factory function to create an ajax record that implements IAjaxRecordInternal + * @param traceCtx - The distributed trace context for the ajax request + * @param logger - The diagnostic logger instance + * @returns An object implementing IAjaxRecordInternal interface + */ +export function createAjaxRecord(traceCtx: IDistributedTraceContext, logger: IDiagnosticLogger): IAjaxRecordInternal { + let _logger: IDiagnosticLogger = logger; + + // Create the ajax record object implementing IAjaxRecordInternal + let ajaxRecord: IAjaxRecordInternal = { + // Initialize all properties with default values + perfMark: null, + completed: false, + requestHeadersSize: null, + requestHeaders: null, + responseReceivingDuration: null, + callbackDuration: null, + ajaxTotalDuration: null, + aborted: 0, + pageUrl: null, + requestUrl: null, + requestSize: 0, + method: null, + status: null, + requestSentTime: null, + responseStartedTime: null, + responseFinishedTime: null, + callbackFinishedTime: null, + endTime: null, + xhrMonitoringState: createXHRMonitoringState(), + clientFailure: 0, + traceCtx: traceCtx, + perfTiming: null, + + getAbsoluteUrl: function(): string { + return ajaxRecord.requestUrl ? urlGetAbsoluteUrl(ajaxRecord.requestUrl) : null; + }, + + getPathName: function(): string { + return ajaxRecord.requestUrl ? dataSanitizeUrl(_logger, urlGetCompleteUrl(ajaxRecord.method, ajaxRecord.requestUrl)) : null; + }, + + CreateTrackItem: function(ajaxType: string, enableRequestHeaderTracking: boolean, getResponse: () => IAjaxRecordResponse): IDependencyTelemetry { + // round to 3 decimal points + ajaxRecord.ajaxTotalDuration = mathRound(dateTimeUtilsDuration(ajaxRecord.requestSentTime, ajaxRecord.responseFinishedTime) * 1000) / 1000; + if (ajaxRecord.ajaxTotalDuration < 0) { + return null; } - - self.getPathName = () => { - return self.requestUrl ? dataSanitizeUrl(_logger, urlGetCompleteUrl(self.method, self.requestUrl)) : null; + + let dependency = { + // Always use the traceId and spanId from the traceCtx, this is the same as the + // traceId and spanId used to create the ajaxRecord, this is to ensure that + // the traceId and spanId are always the same for the ajaxRecord and the dependency + // This is important for the distributed tracing to work correctly + id: "|" + traceCtx.traceId + "." + traceCtx.spanId, + target: ajaxRecord.getAbsoluteUrl(), + name: ajaxRecord.getPathName(), + type: ajaxType, + startTime: null, + duration: ajaxRecord.ajaxTotalDuration, + success: (+(ajaxRecord.status)) >= 200 && (+(ajaxRecord.status)) < 400, + responseCode: (+(ajaxRecord.status)), + [STR_PROPERTIES]: { HttpMethod: ajaxRecord.method } + } as IDependencyTelemetry; + + let props = dependency[STR_PROPERTIES]; + if (ajaxRecord.aborted) { + props.aborted = true; } - - self.CreateTrackItem = (ajaxType:string, enableRequestHeaderTracking:boolean, getResponse:() => IAjaxRecordResponse):IDependencyTelemetry => { - // round to 3 decimal points - self.ajaxTotalDuration = mathRound(dateTimeUtilsDuration(self.requestSentTime, self.responseFinishedTime) * 1000) / 1000; - if (self.ajaxTotalDuration < 0) { - return null; - } - - let dependency = { - id: "|" + self.traceID + "." + self.spanID, - target: self.getAbsoluteUrl(), - name: self.getPathName(), - type: ajaxType, - startTime: null, - duration: self.ajaxTotalDuration, - success: (+(self.status)) >= 200 && (+(self.status)) < 400, - responseCode: (+(self.status)), - [STR_PROPERTIES]: { HttpMethod: self.method } - } as IDependencyTelemetry; - - let props = dependency[STR_PROPERTIES]; - if (self.aborted) { - props.aborted = true; - } - if (self.requestSentTime) { - // Set the correct dependency start time - dependency.startTime = new Date(); - dependency.startTime.setTime(self.requestSentTime); + if (ajaxRecord.requestSentTime) { + // Set the correct dependency start time + dependency.startTime = new Date(); + dependency.startTime.setTime(ajaxRecord.requestSentTime); + } + + // Add Ajax perf details if available + _populatePerfData(this, dependency); + + if (enableRequestHeaderTracking) { + if (objKeys(ajaxRecord.requestHeaders).length > 0) { + props.requestHeaders = ajaxRecord.requestHeaders; } - - // Add Ajax perf details if available - _populatePerfData(self, dependency); - - if (enableRequestHeaderTracking) { - if (objKeys(self.requestHeaders).length > 0) { - props.requestHeaders = self.requestHeaders; + } + + if (getResponse) { + let response: IAjaxRecordResponse = getResponse(); + if (response) { + + // enrich dependency target with correlation context from the server + const correlationContext = response.correlationContext; + if (correlationContext) { + dependency.correlationContext = /* dependency.target + " | " + */ correlationContext; } - } - - if (getResponse) { - let response:IAjaxRecordResponse = getResponse(); - if (response) { - - // enrich dependency target with correlation context from the server - const correlationContext = response.correlationContext; - if (correlationContext) { - dependency.correlationContext = /* dependency.target + " | " + */ correlationContext; + + if (response.headerMap) { + if (objKeys(response.headerMap).length > 0) { + props.responseHeaders = response.headerMap; } - - if (response.headerMap) { - if (objKeys(response.headerMap).length > 0) { - props.responseHeaders = response.headerMap; + } + + if (ajaxRecord.errorStatusText) { + if ((+(ajaxRecord.status)) >= 400) { + const responseType = response.type; + if (responseType === "" || responseType === "text") { + props.responseText = response.responseText ? response.statusText + " - " + response.responseText : response.statusText; } - } - - if (self.errorStatusText) { - if (self.status >= 400) { - const responseType = response.type; - if (responseType === "" || responseType === "text") { - props.responseText = response.responseText ? response.statusText + " - " + response[strResponseText] : response.statusText; - } - if (responseType === "json") { - props.responseText = response.response ? response.statusText + " - " + JSON.stringify(response.response) : response.statusText; - } - } else if (self.status === 0) { - props.responseText = response.statusText || ""; + if (responseType === "json") { + props.responseText = response.response ? response.statusText + " - " + JSON.stringify(response.response) : response.statusText; } + } else if (ajaxRecord.status === 0) { + props.responseText = response.statusText || ""; } } } - - return dependency; } - self.getPartAProps = () => { - let partA: { [key: string]: any } = null; - - let traceCtx = self.eventTraceCtx; - if (traceCtx && (traceCtx.traceId || traceCtx.spanId)) { - partA = {}; - let traceExt = partA[Extensions.TraceExt] = { - traceID: traceCtx.traceId, - parentID: traceCtx.spanId - } as { [key: string]: any }; + return dependency; + }, - if (!isNullOrUndefined(traceCtx.traceFlags)) { - traceExt.traceFlags = traceCtx.traceFlags; - } - } + getPartAProps: function(): { [key: string]: any } { + let partA: { [key: string]: any } = null; - return partA - }; - }); - } + let parentCtx = ajaxRecord.traceCtx.parentCtx; + if (parentCtx && (parentCtx.traceId || parentCtx.spanId)) { + partA = {}; + let traceExt = partA[Extensions.TraceExt] = { + traceID: parentCtx.traceId, + parentID: parentCtx.spanId + } as { [key: string]: any }; - public getAbsoluteUrl(): string { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return null; - } - - public getPathName(): string { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return null; - } + if (!isNullOrUndefined(parentCtx.traceFlags)) { + traceExt.traceFlags = parentCtx.traceFlags; + } + } - public CreateTrackItem(ajaxType:string, enableRequestHeaderTracking:boolean, getResponse:() => IAjaxRecordResponse):IDependencyTelemetry { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return null; - } + return partA; + } + }; - public getPartAProps(): { [key: string]: any } { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return null; - } + return ajaxRecord; } diff --git a/extensions/applicationinsights-dependencies-js/src/ajaxUtils.ts b/extensions/applicationinsights-dependencies-js/src/ajaxUtils.ts deleted file mode 100644 index 2132a6480..000000000 --- a/extensions/applicationinsights-dependencies-js/src/ajaxUtils.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { isNullOrUndefined } from "@microsoft/applicationinsights-core-js"; - -export class stringUtils { - public static GetLength(strObject: any) { - let res = 0; - if (!isNullOrUndefined(strObject)) { - let stringified = ""; - try { - stringified = strObject.toString(); - } catch (ex) { - // some troubles with complex object - } - - res = stringified.length; - res = isNaN(res) ? 0 : res; - } - - return res; - } -} diff --git a/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts b/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts index 7280d0936..4ca0cb17a 100644 --- a/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts +++ b/extensions/applicationinsights-dependencies-js/src/applicationinsights-dependencies-js.ts @@ -2,9 +2,8 @@ // Licensed under the MIT License. export { - AjaxMonitor as AjaxPlugin, IDependenciesPlugin, XMLHttpRequestData, XMLHttpRequestInstrumented, IInstrumentationRequirements, DfltAjaxCorrelationHeaderExDomains + AjaxMonitor as AjaxPlugin, IDependenciesPlugin, XMLHttpRequestData, XMLHttpRequestInstrumented, IInstrumentationRequirements, DfltAjaxCorrelationHeaderExDomains, IAjaxRecordData } from "./ajax"; -export { ajaxRecord } from "./ajaxRecord"; export { IDependencyHandler, IDependencyListenerHandler, IDependencyListenerDetails, DependencyListenerFunction } from "./DependencyListener"; export { IDependencyInitializerHandler, IDependencyInitializerDetails, DependencyInitializerFunction } from "./DependencyInitializer"; export { ICorrelationConfig } from "@microsoft/applicationinsights-common"; diff --git a/extensions/applicationinsights-properties-js/Tests/Unit/src/TelemetryContext.Tests.ts b/extensions/applicationinsights-properties-js/Tests/Unit/src/TelemetryContext.Tests.ts index 19e007e2b..bec6fece4 100644 --- a/extensions/applicationinsights-properties-js/Tests/Unit/src/TelemetryContext.Tests.ts +++ b/extensions/applicationinsights-properties-js/Tests/Unit/src/TelemetryContext.Tests.ts @@ -44,21 +44,24 @@ export class TelemetryContextTests extends AITestClass { this.testCase({ name: 'TelemetryContext: applyOperationContext - default', test: () => { + let coreParentId = this.core.getTraceCtx()?.getSpanId(); + let coreTraceId = this.core.getTraceCtx()?.getTraceId(); let context = new TelemetryContext(this.core, this._config.extensionConfig!.AppInsightsPropertiesPlugin); let theEvent = {} as any; context.applyOperationContext(theEvent); Assert.equal(context.telemetryTrace.traceID, theEvent.ext.trace.traceID, "Validate traceId"); - Assert.equal(undefined, theEvent.ext.trace.parentID, "No ParentID"); + Assert.equal(coreTraceId, theEvent.ext.trace.traceID, "Validate traceId"); + Assert.equal(coreParentId, theEvent.ext.trace.parentID, "ParentID matches the core spanId"); } - }); this.testCase({ name: 'TelemetryContext: applyOperationContext - does not override traceId', test: () => { + Assert.ok(this.core, "Core is not null"); let context = new TelemetryContext(this.core, this._config.extensionConfig!.AppInsightsPropertiesPlugin); let theEvent = { ext: { @@ -69,7 +72,9 @@ export class TelemetryContextTests extends AITestClass { } as any; context.telemetryTrace.traceID = "defaultTraceId"; + Assert.equal("defaultTraceId", context.telemetryTrace.traceID, "traceId should be defaultTraceId"); context.telemetryTrace.parentID = "defaultParentId"; + Assert.equal("defaultParentId", context.telemetryTrace.parentID, "parentId should be defaultParentId"); context.applyOperationContext(theEvent); diff --git a/extensions/applicationinsights-properties-js/Tests/Unit/src/properties.tests.ts b/extensions/applicationinsights-properties-js/Tests/Unit/src/properties.tests.ts index 7a2cde097..69630dafa 100644 --- a/extensions/applicationinsights-properties-js/Tests/Unit/src/properties.tests.ts +++ b/extensions/applicationinsights-properties-js/Tests/Unit/src/properties.tests.ts @@ -3,7 +3,6 @@ import { AppInsightsCore, IConfiguration, DiagnosticLogger, ITelemetryItem, crea import PropertiesPlugin from "../../../src/PropertiesPlugin"; import { IPropertiesConfig } from "../../../src/Interfaces/IPropertiesConfig"; import { TelemetryContext } from "../../../src/TelemetryContext"; -import { TelemetryTrace } from "../../../src/Context/TelemetryTrace"; import { IConfig, utlCanUseLocalStorage, utlGetLocalStorage } from "@microsoft/applicationinsights-common"; import { TestChannelPlugin } from "./TestChannelPlugin"; import { SinonStub } from 'sinon'; @@ -65,7 +64,18 @@ export class PropertiesTests extends AITestClass { this.testCase({ name: 'Trace: default operation.name is grabbed from window pathname, if available', test: () => { - const operation = new TelemetryTrace(); + this.properties.initialize({ + instrumentationKey: 'instrumentation_key', + accountId: 'abc', + samplingPercentage: 15, + sessionExpirationMs: 99999, + extensionConfig: { + [this.properties.identifier]: { + sessionExpirationMs: 88888 + } + } + }, this.core, []); + const operation = this.properties.context.telemetryTrace; Assert.ok(operation.name); } }); @@ -73,8 +83,19 @@ export class PropertiesTests extends AITestClass { this.testCase({ name: 'Trace: operation.name is truncated to max size 1024 if too long', test: () => { - const name = new Array(1234).join("a"); // exceeds max of 1024 - const operation = new TelemetryTrace(undefined, undefined, name, this.core.logger); + this.properties.initialize({ + instrumentationKey: 'instrumentation_key', + accountId: 'abc', + samplingPercentage: 15, + sessionExpirationMs: 99999, + extensionConfig: { + [this.properties.identifier]: { + sessionExpirationMs: 88888 + } + } + }, this.core, []); + const operation = this.properties.context.telemetryTrace; + operation.name = new Array(1234).join("a"); // exceeds max of 1024 Assert.ok(operation.name); Assert.equal(operation.name.length, 1024); } diff --git a/extensions/applicationinsights-properties-js/src/Context/TelemetryTrace.ts b/extensions/applicationinsights-properties-js/src/Context/TelemetryTrace.ts deleted file mode 100644 index d5e35a5f3..000000000 --- a/extensions/applicationinsights-properties-js/src/Context/TelemetryTrace.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { ITelemetryTrace, ITraceState, dataSanitizeString } from "@microsoft/applicationinsights-common"; -import { IConfiguration, IDiagnosticLogger, fieldRedaction, generateW3CId, getLocation } from "@microsoft/applicationinsights-core-js"; - -export class TelemetryTrace implements ITelemetryTrace { - - public traceID: string; - public parentID: string; - public traceState: ITraceState; - public traceFlags: number; - public name: string; - - constructor(id?: string, parentId?: string, name?: string, logger?: IDiagnosticLogger, config?: IConfiguration) { - const _self = this; - _self.traceID = id || generateW3CId(); - _self.parentID = parentId; - let location = getLocation(); - if (!name && location && location.pathname) { - name = location.pathname; - if (config) { - name = fieldRedaction(name, config); - } - } - - _self.name = dataSanitizeString(logger, name); - } -} diff --git a/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts b/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts index 842e620d9..4c971dfca 100644 --- a/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts +++ b/extensions/applicationinsights-properties-js/src/PropertiesPlugin.ts @@ -5,15 +5,14 @@ import dynamicProto from "@microsoft/dynamicproto-js"; import { - BreezeChannelIdentifier, IConfig, IPropertiesPlugin, PageView, PropertiesPluginIdentifier, createDistributedTraceContextFromTrace, - utlSetStoragePrefix + BreezeChannelIdentifier, IConfig, IPropertiesPlugin, PageView, PropertiesPluginIdentifier, utlSetStoragePrefix } from "@microsoft/applicationinsights-common"; import { - BaseTelemetryPlugin, IAppInsightsCore, IConfigDefaults, IConfiguration, IDistributedTraceContext, IPlugin, IProcessTelemetryContext, + BaseTelemetryPlugin, IAppInsightsCore, IConfigDefaults, IConfiguration, IPlugin, IProcessTelemetryContext, IProcessTelemetryUnloadContext, ITelemetryItem, ITelemetryPluginChain, ITelemetryUnloadState, _InternalLogMessage, _eInternalMessageId, _logInternalMessage, createProcessTelemetryContext, eLoggingSeverity, getNavigator, getSetValue, isNullOrUndefined, onConfigChange } from "@microsoft/applicationinsights-core-js"; -import { objDeepFreeze, objDefine } from "@nevware21/ts-utils"; +import { isString, objDeepFreeze, objDefine } from "@nevware21/ts-utils"; import { IPropTelemetryContext } from "./Interfaces/IPropTelemetryContext"; import { IPropertiesConfig } from "./Interfaces/IPropertiesConfig"; import { TelemetryContext } from "./TelemetryContext"; @@ -49,8 +48,6 @@ export default class PropertiesPlugin extends BaseTelemetryPlugin implements IPr super(); let _extensionConfig: IPropertiesConfig; - let _distributedTraceCtx: IDistributedTraceContext; - let _previousTraceCtx: IDistributedTraceContext; let _context: IPropTelemetryContext; let _disableUserInitMessage: boolean; @@ -81,11 +78,11 @@ export default class PropertiesPlugin extends BaseTelemetryPlugin implements IPr itemCtx.diagLog().resetInternalMessageCount(); } - let theContext: TelemetryContext = (_context || {}) as TelemetryContext; + let theContext: IPropTelemetryContext = (_context || {}) as IPropTelemetryContext; if (theContext.session) { // If customer did not provide custom session id update the session manager - if (typeof _context.session.id !== "string" && theContext.sessionManager) { + if (!isString(_context.session.id) && theContext.sessionManager) { theContext.sessionManager.update(); } } @@ -110,21 +107,11 @@ export default class PropertiesPlugin extends BaseTelemetryPlugin implements IPr }; _self._doTeardown = (unloadCtx?: IProcessTelemetryUnloadContext, unloadState?: ITelemetryUnloadState) => { - let core = (unloadCtx || {} as any).core(); - if (core && core.getTraceCtx) { - let traceCtx = core.getTraceCtx(false); - if (traceCtx === _distributedTraceCtx) { - core.setTraceCtx(_previousTraceCtx); - } - } - _initDefaults(); }; function _initDefaults() { _extensionConfig = null; - _distributedTraceCtx = null; - _previousTraceCtx = null; _context = null; _disableUserInitMessage = true; } @@ -147,10 +134,7 @@ export default class PropertiesPlugin extends BaseTelemetryPlugin implements IPr })); // This is outside of the onConfigChange as we don't want to update (replace) these values whenever a referenced config item changes - _previousTraceCtx = core.getTraceCtx(false); - _context = new TelemetryContext(core, _extensionConfig, _previousTraceCtx, _self._unloadHooks); - _distributedTraceCtx = createDistributedTraceContextFromTrace(_self.context.telemetryTrace, _previousTraceCtx); - core.setTraceCtx(_distributedTraceCtx); + _context = new TelemetryContext(core, _extensionConfig, _self._unloadHooks); _self.context.appId = () => { let breezeChannel = core.getPlugin(BreezeChannelIdentifier); return breezeChannel ? breezeChannel.plugin["_appId"] : null; diff --git a/extensions/applicationinsights-properties-js/src/TelemetryContext.ts b/extensions/applicationinsights-properties-js/src/TelemetryContext.ts index 0c62bbb1e..3ac777c5b 100644 --- a/extensions/applicationinsights-properties-js/src/TelemetryContext.ts +++ b/extensions/applicationinsights-properties-js/src/TelemetryContext.ts @@ -12,18 +12,21 @@ 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 { Application } from "./Context/Application"; import { Device } from "./Context/Device"; import { Internal } from "./Context/Internal"; import { Location } from "./Context/Location"; import { Session, _SessionManager } from "./Context/Session"; -import { TelemetryTrace } from "./Context/TelemetryTrace"; import { User } from "./Context/User"; import { IPropTelemetryContext } from "./Interfaces/IPropTelemetryContext"; import { IPropertiesConfig } from "./Interfaces/IPropertiesConfig"; const strExt = "ext"; const strTags = "tags"; +let UNDEF_VALUE: undefined; function _removeEmpty(target: any, name: string) { if (target && target[name] && objKeys(target[name]).length === 0) { @@ -35,11 +38,135 @@ function _nullResult(): string { return null; } +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; + 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 = {}; + } + + if (!isUndefined(trace[name])) { + // has local value + value = trace[name]; + } else if (ctx) { + if (name in ctx) { + // has property + value = (ctx as any)[name] as T; + } else { + let fnName = "get" + strLetterCase(name); + if (isFunction((ctx as any)[fnName])) { + value = (ctx as any)[fnName]; + } + } + + if (isFunction(value)) { + // The return values was a function, call it + value = fnCall(value as any, ctx); + } + } + + return value; + } + + 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 = {}; + } + + if (ctx) { + if (name in ctx) { + if (isFunction((ctx as any)[name])) { + // The return values was a function, call it + fnCall((ctx as any)[name], ctx, [value]); + } else { + (ctx as any)[name] = value; + } + } else { + let fnName = "set" + strLetterCase(name); + if (isFunction((ctx as any)[fnName])) { + (ctx as any)[fnName](value); + } + } + + // For backward compatability, we need to support invalid values for historic reasons, + // moving forward we have marked the usage of the telemetryTrace as deprecated and will be removed in a future version. + // We will only set the value in the local trace context if it is a valid trace ID or a string, otherwise we will remove it + trace[name] = UNDEF_VALUE; + if (value && isString(value)) { + // If the value is null or undefined, remove it from the local trace context + if (checkFn && checkFn() !== value) { + // If the values doesn't match (most likely because the value is invalid), set the value in the local trace context + coreTraceCtx = ctx; + trace[name] = value; + } + } + } + } + + function _getTraceId() { + return _getTraceCtx("traceId"); + } + + function _getParentId() { + return _getTraceCtx("spanId"); + } + + function _getTraceFlags() { + return _getTraceCtx("traceFlags"); + } + + function _getName() { + return dataSanitizeString(core ? core.logger : null, _getTraceCtx("getName") || _getTraceCtx("pageName")); + } + + function _setValue(name: keyof IDistributedTraceContext extends string ? keyof IDistributedTraceContext : never, checkFn?: () => V): (value: V) => void { + return function (value: V) { + _setTraceCtx(name, value, checkFn); + }; + } + + return objDefineProps({}, { + traceID: { + g: _getTraceId, + s: _setValue("traceId", _getTraceId)}, + parentID: { + g: _getParentId, + s: _setValue("spanId", _getParentId) + }, + traceFlags: { + g: _getTraceFlags, + s: _setValue("traceFlags", _getTraceFlags) + }, + name: { + g: _getName, + s: _setValue("pageName", _getName) + } + }); +} + export class TelemetryContext implements IPropTelemetryContext { public application: IApplication; // The object describing a component tracked by this object - legacy public device: IDevice; // The object describing a device tracked by this object. public location: ILocation; // The object describing a location tracked by this object -legacy + + /** + * The object describing a telemetry operation tracked by this object, values applied to this object will be + * applied to the telemetry being processed and the values will override the values in the {@link IAppInsightsCore.getTraceCtx} + * property, thus any new {@link IDistributedTraceContext} values will be ignored. + * @deprecated (since v3.4.0) This property is now being marked as deprecated and provided in the current releases for backward + * compatability only, it will be removed in a future version. Use the {@link IAppInsightsCore.getTraceCtx} property + * instead. + * @remarks Any "updates" to the telemetryTrace will NOT be reflected in the {@link IAppInsightsCore.getTraceCtx} property, however, if no values + * are set on the telemetryTrace, the {@link IAppInsightsCore.getTraceCtx} property will be used to get the values. + */ public telemetryTrace: ITelemetryTrace; // The object describing a operation tracked by this object. public user: IUserContext; // The object describing a user tracked by this object. public internal: IInternal; // legacy @@ -50,7 +177,7 @@ export class TelemetryContext implements IPropTelemetryContext { public appId: () => string; public getSessionId: () => string; - constructor(core: IAppInsightsCore, defaultConfig: IPropertiesConfig, previousTraceCtx?: IDistributedTraceContext, unloadHookContainer?: IUnloadHookContainer) { + constructor(core: IAppInsightsCore, defaultConfig: IPropertiesConfig, unloadHookContainer?: IUnloadHookContainer) { let logger = core.logger dynamicProto(TelemetryContext, this, (_self) => { @@ -63,17 +190,11 @@ export class TelemetryContext implements IPropTelemetryContext { _self.device = new Device(); _self.location = new Location(); _self.user = new User(defaultConfig, core, unloadHookContainer); - - let traceId: string; - let parentId: string; - let name: string; - if (previousTraceCtx) { - traceId = previousTraceCtx.getTraceId(); - parentId = previousTraceCtx.getSpanId(); - name = previousTraceCtx.getName(); - } - _self.telemetryTrace = new TelemetryTrace(traceId, parentId, name, logger); _self.session = new Session(); + + objDefine(_self, "telemetryTrace", { + l: createDeferredCachedValue(() => _createTelemetryTrace(core)) + }); } _self.getSessionId = () => { diff --git a/extensions/applicationinsights-properties-js/src/applicationinsights-properties-js.ts b/extensions/applicationinsights-properties-js/src/applicationinsights-properties-js.ts index 8b7c1f872..23093f467 100644 --- a/extensions/applicationinsights-properties-js/src/applicationinsights-properties-js.ts +++ b/extensions/applicationinsights-properties-js/src/applicationinsights-properties-js.ts @@ -2,9 +2,8 @@ // Licensed under the MIT License. import PropertiesPlugin from "./PropertiesPlugin"; +import { ITelemetryTrace } from "@microsoft/applicationinsights-common"; import { ISessionConfig, Session, _SessionManager } from "./Context/Session"; -import { TelemetryTrace } from "./Context/TelemetryTrace"; import { IPropTelemetryContext } from "./Interfaces/IPropTelemetryContext"; -import { TelemetryContext } from "./TelemetryContext"; -export { PropertiesPlugin, TelemetryTrace, TelemetryContext, Session, ISessionConfig, IPropTelemetryContext, _SessionManager as SessionManager }; +export { PropertiesPlugin, ITelemetryTrace, Session, ISessionConfig, IPropTelemetryContext, _SessionManager as SessionManager }; diff --git a/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts b/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts index c562246d5..ef59e6932 100644 --- a/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts +++ b/shared/1ds-core-js/test/Unit/src/FileSizeCheckTest.ts @@ -51,8 +51,8 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class FileSizeCheckTest extends AITestClass { - private readonly MAX_BUNDLE_SIZE = 70; - private readonly MAX_DEFLATE_SIZE = 30; + private readonly MAX_BUNDLE_SIZE = 80; + private readonly MAX_DEFLATE_SIZE = 34; private readonly bundleFilePath = "../bundle/es5/ms.core.min.js"; public testInitialize() { diff --git a/shared/AppInsightsCommon/Tests/Unit/src/W3CTraceStateModes.tests.ts b/shared/AppInsightsCommon/Tests/Unit/src/W3CTraceStateModes.tests.ts new file mode 100644 index 000000000..a89f42059 --- /dev/null +++ b/shared/AppInsightsCommon/Tests/Unit/src/W3CTraceStateModes.tests.ts @@ -0,0 +1,224 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { eDistributedTracingModes } from "../../../src/Enums"; + +/** + * Helper function to check if a mode should include tracestate header + * This matches the internal implementation in the SDK + */ +function _checkTraceStateBit(distributedTracingMode: eDistributedTracingModes): boolean { + // Use the proper bitwise check for the _W3CTraceState flag (which is an actual bit flag) + return (distributedTracingMode & eDistributedTracingModes._W3CTraceState) === eDistributedTracingModes._W3CTraceState; +} + +/** + * Tests for W3C TraceState Configuration with different distributed tracing modes + */ +export class W3CTraceStateModesTests extends AITestClass { + + public testInitialize() { + // No special setup required + } + + public testCleanup() { + // No special cleanup required + } + + public registerTests() { + this.testCase({ + name: "W3CTraceStateModes: Bit masks work correctly for trace modes", + test: () => { + // Test AI_AND_W3C_TRACE mode + var tracingMode = eDistributedTracingModes.AI_AND_W3C_TRACE; + + // Get the base mode using _BaseMask + var baseMode = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert base mode equals AI_AND_W3C + Assert.equal(baseMode, eDistributedTracingModes.AI_AND_W3C, + "AI_AND_W3C_TRACE base mode should be AI_AND_W3C"); + + // Assert W3C_TRACE bit is set (testing the _W3CTraceState bit) + Assert.equal(true, _checkTraceStateBit(tracingMode), + "AI_AND_W3C_TRACE should include _W3CTraceState bit"); + + // Test W3C_TRACE mode + tracingMode = eDistributedTracingModes.W3C_TRACE; + + // Get the base mode using _BaseMask + baseMode = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert base mode equals W3C + Assert.equal(baseMode, eDistributedTracingModes.W3C, + "W3C_TRACE base mode should be W3C"); + + // Assert it's not equal to AI mode + Assert.notEqual(baseMode, eDistributedTracingModes.AI, + "W3C_TRACE base mode should NOT be AI"); + + // Assert W3C_TRACE bit is set + Assert.equal(true, _checkTraceStateBit(tracingMode), + "W3C_TRACE should include _W3CTraceState bit"); + + // Test AI_AND_W3C mode (without the trace bit) + tracingMode = eDistributedTracingModes.AI_AND_W3C; + + // Get base mode - for AI_AND_W3C it's already the base mode + baseMode = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert it equals AI_AND_W3C + Assert.equal(baseMode, eDistributedTracingModes.AI_AND_W3C, + "AI_AND_W3C should be its own base mode"); + + // Assert it's not equal to just AI mode + Assert.notEqual(baseMode, eDistributedTracingModes.AI, + "AI_AND_W3C should NOT be AI"); + + // Assert W3C_TRACE bit is NOT set + Assert.equal(false, _checkTraceStateBit(tracingMode), + "AI_AND_W3C should NOT include _W3CTraceState bit"); + + // Test W3C mode (without trace bit) + tracingMode = eDistributedTracingModes.W3C; + + // Get the base mode - for W3C it's already the base mode + baseMode = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert it equals W3C + Assert.equal(baseMode, eDistributedTracingModes.W3C, + "W3C should be its own base mode"); + + // Assert it's not equal to AI mode + Assert.notEqual(baseMode, eDistributedTracingModes.AI, + "W3C should NOT be AI"); + + // Assert W3C_TRACE bit is NOT set + Assert.equal(false, _checkTraceStateBit(tracingMode), + "W3C should NOT include _W3CTraceState bit"); + } + }); + + this.testCase({ + name: "W3CTraceStateModes: Changing mode dynamically updates tracestate header behavior", + test: () => { + // First set mode without tracestate bit + var tracingMode = eDistributedTracingModes.AI_AND_W3C; + + // Verify no tracestate initially + Assert.equal(false, _checkTraceStateBit(tracingMode), + "AI_AND_W3C should NOT include _W3CTraceState bit"); + + // Change to mode with tracestate bit + tracingMode = eDistributedTracingModes.AI_AND_W3C_TRACE; + + // Verify tracestate bit is now set + Assert.equal(true, _checkTraceStateBit(tracingMode), + "AI_AND_W3C_TRACE should include _W3CTraceState bit"); + + // Change to W3C_TRACE mode + tracingMode = eDistributedTracingModes.W3C_TRACE; + + // Verify tracestate bit is still set + Assert.equal(true, _checkTraceStateBit(tracingMode), + "W3C_TRACE should include _W3CTraceState bit"); + + // Get the base mode + var baseMode2 = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert base mode is W3C and not AI + Assert.equal(baseMode2, eDistributedTracingModes.W3C, + "W3C_TRACE base mode should be W3C"); + Assert.notEqual(baseMode2, eDistributedTracingModes.AI, + "W3C_TRACE base mode should NOT be AI"); + + // Verify bitmask calculations work correctly + Assert.equal(eDistributedTracingModes.W3C_TRACE, + eDistributedTracingModes.W3C | eDistributedTracingModes._W3CTraceState, + "W3C_TRACE should equal W3C | _W3CTraceState"); + + Assert.equal(eDistributedTracingModes.AI_AND_W3C_TRACE, + eDistributedTracingModes.AI_AND_W3C | eDistributedTracingModes._W3CTraceState, + "AI_AND_W3C_TRACE should equal AI_AND_W3C | _W3CTraceState"); + } + }); + + this.testCase({ + name: "W3CTraceStateModes: _BaseMask correctly isolates base mode from tracestate bit", + test: () => { + // Test AI_AND_W3C_TRACE mode with _BaseMask + var tracingMode = eDistributedTracingModes.AI_AND_W3C_TRACE; + var baseMode = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert base mode equals AI_AND_W3C + Assert.equal(baseMode, eDistributedTracingModes.AI_AND_W3C, + "Base mode of AI_AND_W3C_TRACE should be AI_AND_W3C"); + + // Test W3C_TRACE mode with _BaseMask + tracingMode = eDistributedTracingModes.W3C_TRACE; + baseMode = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert base mode equals W3C + Assert.equal(baseMode, eDistributedTracingModes.W3C, + "Base mode of W3C_TRACE should be W3C"); + + // Test that masking doesn't affect modes without the trace bit + tracingMode = eDistributedTracingModes.AI; + baseMode = tracingMode & eDistributedTracingModes._BaseMask; + + // Assert base mode equals AI + Assert.equal(baseMode, eDistributedTracingModes.AI, + "Base mode of AI should still be AI after applying mask"); + } + }); + + this.testCase({ + name: "W3CTraceStateModes: Enable and disable tracestate bit dynamically", + test: () => { + // Start with AI_AND_W3C mode + var baseMode = eDistributedTracingModes.AI_AND_W3C; + + // Verify no tracestate initially + Assert.equal(false, _checkTraceStateBit(baseMode), + "AI_AND_W3C should NOT include _W3CTraceState bit initially"); + + // Add the tracestate bit (|= operation) + var updatedMode = baseMode | eDistributedTracingModes._W3CTraceState; + + // Verify it equals AI_AND_W3C_TRACE + Assert.equal(updatedMode, eDistributedTracingModes.AI_AND_W3C_TRACE, + "Adding _W3CTraceState bit should result in AI_AND_W3C_TRACE"); + + // Verify tracestate bit is now set + Assert.equal(true, _checkTraceStateBit(updatedMode), + "Updated mode should include _W3CTraceState bit"); + + // Remove the tracestate bit (& ~operation) + var restoredMode = updatedMode & ~eDistributedTracingModes._W3CTraceState; + + // Verify it equals the original mode + Assert.equal(restoredMode, baseMode, + "Removing _W3CTraceState bit should restore original mode"); + + // Verify tracestate bit is no longer set + Assert.equal(false, _checkTraceStateBit(restoredMode), + "Restored mode should NOT include _W3CTraceState bit"); + + // Start with W3C mode + baseMode = eDistributedTracingModes.W3C; + + // Add the tracestate bit + updatedMode = baseMode | eDistributedTracingModes._W3CTraceState; + + // Verify it equals W3C_TRACE + Assert.equal(updatedMode, eDistributedTracingModes.W3C_TRACE, + "Adding _W3CTraceState bit to W3C should result in W3C_TRACE"); + + // Remove the tracestate bit + restoredMode = updatedMode & ~eDistributedTracingModes._W3CTraceState; + + // Verify it equals original W3C mode + Assert.equal(restoredMode, eDistributedTracingModes.W3C, + "Removing _W3CTraceState bit should restore W3C mode"); + } + }); + } +} diff --git a/shared/AppInsightsCommon/Tests/Unit/src/appinsights-common.tests.ts b/shared/AppInsightsCommon/Tests/Unit/src/appinsights-common.tests.ts index f9ef32c0d..956ae0834 100644 --- a/shared/AppInsightsCommon/Tests/Unit/src/appinsights-common.tests.ts +++ b/shared/AppInsightsCommon/Tests/Unit/src/appinsights-common.tests.ts @@ -6,6 +6,7 @@ import { SeverityLevelTests } from "./SeverityLevel.tests"; import { RequestHeadersTests } from "./RequestHeaders.tests"; import { ThrottleMgrTest } from "./ThrottleMgr.tests"; import { GlobalTestHooks } from "./GlobalTestHooks.Test"; +import { W3CTraceStateModesTests } from "./W3CTraceStateModes.tests"; export function runTests() { new GlobalTestHooks().registerTests(); @@ -16,4 +17,5 @@ export function runTests() { new ConnectionStringParserTests().registerTests(); new SeverityLevelTests().registerTests(); new RequestHeadersTests().registerTests(); + new W3CTraceStateModesTests().registerTests(); } diff --git a/shared/AppInsightsCommon/src/Enums.ts b/shared/AppInsightsCommon/src/Enums.ts index a8c159c3d..64d10c71d 100644 --- a/shared/AppInsightsCommon/src/Enums.ts +++ b/shared/AppInsightsCommon/src/Enums.ts @@ -25,26 +25,96 @@ export const enum FieldType { Default = 0, Required = 1, Array = 2, Hidden = 4 } export const enum eDistributedTracingModes { /** - * (Default) Send Application Insights correlation headers + * Send only the legacy Application Insights correlation headers + * + * Headers Sent: + * - `Request-Id` (Legacy Application Insights header for older Server side SDKs) + * + * Config Decimal Value: `0` (Zero) */ + AI = 0x00, - AI = 0, + /** + * (Default) Send both W3C Trace parent header and back-compatibility Application Insights headers + * - `Request-Id` + * - [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) + * + * Config Decimal Value: `1` (One) + */ + AI_AND_W3C = 0x01, + + /** + * Send Only the W3C Trace parent header + * + * Headers Sent: + * - [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) + * + * Config Decimal Value: `2` (Two) + */ + W3C = 0x02, + + /** + * @internal + * Bitwise mask used to separate the base distributed tracing mode from the additional optional + * tracing modes. + * @since 3.4.0 + */ + _BaseMask = 0x0F, // Mask to get the base distributed tracing mode + + /** + * @internal + * Enabling this bit will send the W3C Trace State header, it is not intended to be used directly + * or on its own. The code may assume that if this bit is set, then the W3C Trace Context headers + * will also be included. + * + * Config Decimal Value: `16` (Sixteen in decimal) + * @since 3.4.0 + */ + _W3CTraceState = 0x10, // Bit mask to enable sending the W3C Trace State headers /** - * Send both W3C Trace Context headers and back-compatibility Application Insights headers + * Send all of the W3C Trace Context headers and the W3C Trace State headers and back-compatibility + * Application Insights headers. + * + * Currently sent headers: + * - `Request-Id` (Legacy Application Insights header for older Server side SDKs) + * - [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) + * - [`tracestate`](https://www.w3.org/TR/trace-context/#tracestate-header) + * + * NOTE!: Additional headers may be added as part of a future update should the W3C Trace Context specification be updated + * to include additional headers. + * + * Config Decimal Value: `17` (Seventeen in decimal) + * @since 3.4.0 */ - AI_AND_W3C, + AI_AND_W3C_TRACE = AI_AND_W3C | _W3CTraceState, /** - * Send W3C Trace Context headers + * Send all of the W3C Trace Context headers and the W3C Trace State headers. + * + * Currently sent headers: + * - [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) + * - [`tracestate`](https://www.w3.org/TR/trace-context/#tracestate-header) + * + * NOTE!: Additional headers may be added as part of a future update should the W3C Trace Context specification be updated + * to include additional headers. + * + * Config Decimal Value: `18` (Eighteen in decimal) + * @since 3.4.0 */ - W3C + W3C_TRACE = W3C | _W3CTraceState } export const DistributedTracingModes = (/* @__PURE__ */ createEnumStyle({ AI: eDistributedTracingModes.AI, AI_AND_W3C: eDistributedTracingModes.AI_AND_W3C, - W3C: eDistributedTracingModes.W3C + W3C: eDistributedTracingModes.W3C, + AI_AND_W3C_TRACE: eDistributedTracingModes.AI_AND_W3C_TRACE, + W3C_TRACE: eDistributedTracingModes.W3C_TRACE, + + // Internal mask values + _BaseMask: eDistributedTracingModes._BaseMask, + _W3CTraceState: eDistributedTracingModes._W3CTraceState })); export type DistributedTracingModes = number | eDistributedTracingModes; diff --git a/shared/AppInsightsCommon/src/Interfaces/Context/ITelemetryTrace.ts b/shared/AppInsightsCommon/src/Interfaces/Context/ITelemetryTrace.ts index 4b12ebf00..6df218204 100644 --- a/shared/AppInsightsCommon/src/Interfaces/Context/ITelemetryTrace.ts +++ b/shared/AppInsightsCommon/src/Interfaces/Context/ITelemetryTrace.ts @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +/** + * Interface for telemetry trace context. + * @deprecated Use the core getTraceCtx method instead to get / set the current trace context, this is required to + * support distributed tracing and allows the core to manage the trace context. + */ export interface ITelemetryTrace { /** * Trace id @@ -12,12 +17,6 @@ export interface ITelemetryTrace { */ parentID?: string; - /** - * @deprecated Never Used - * Trace state - */ - traceState?: ITraceState; - /** * An integer representation of the W3C TraceContext trace-flags. https://www.w3.org/TR/trace-context/#trace-flags */ @@ -28,6 +27,3 @@ export interface ITelemetryTrace { */ name?: string; } - -export interface ITraceState { -} \ No newline at end of file diff --git a/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts b/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts index 1f1de850c..d9ee24a0f 100644 --- a/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts +++ b/shared/AppInsightsCommon/src/Interfaces/ICorrelationConfig.ts @@ -7,10 +7,73 @@ import { IRequestContext } from "./IRequestContext"; export interface ICorrelationConfig { enableCorsCorrelation: boolean; + + /** + * [Optional] Domains to be excluded from correlation headers. + * To override or discard the default, add an array with all domains to be excluded or + * an empty array to the configuration. + * + * @example + * ```ts + * import { ApplicationInsights } from '@microsoft/applicationinsights-web'; + * const appInsights = new ApplicationInsights({ + * config: { + * connectionString: 'InstrumentationKey=YOUR_INSTRUMENTATION_KEY_GOES_HERE', + * extensionConfig: { + * AjaxDependencyPlugin: { + * // Both arrays of strings are used to match the request URL against the + * // current host and the request URL to determine if correlation headers + * // The strings are converted to RegExp objects by translating + * // - `.` to `\\.` (to match a literal dot) + * // - `*` to `.*` (to match any character) + * // - `\` to `\\` (to match a literal slash) + * // All other characters are ignored and passed to the RegExp constructor + * correlationHeaderExcludedDomains: ["test", "*.azure.com", "ignore.microsoft.com"], + * correlationHeaderDomains: ["azure.com", "prefix.bing.com", "*.microsoft.com", "example.com"] + * } + * } + * }); + * appInsights.loadAppInsights(); + * appInsights.trackPageView(); // Manually call trackPageView to establish the current user/session/pageview + * ``` + */ correlationHeaderExcludedDomains: string[]; + + /** + * [Optional] Domains to be included in correlation headers. + * To override or discard the default, add an array with all domains to be included or + * an empty array to the configuration. + * + * @example + * ```ts + * import { ApplicationInsights } from '@microsoft/applicationinsights-web'; + * const appInsights = new ApplicationInsights({ + * config: { + * connectionString: 'InstrumentationKey=YOUR_INSTRUMENTATION_KEY_GOES_HERE', + * extensionConfig: { + * AjaxDependencyPlugin: { + * // Values MUST be RegExp objects + * correlationHeaderExcludePatterns: [/*\.azure.com/, /prefix.bing.com/, /.*\.microsoft.com/, /example.com/] + * } + * } + * }); + * appInsights.loadAppInsights(); + * appInsights.trackPageView(); // Manually call trackPageView to establish the current user/session/pageview + * ``` + */ correlationHeaderExcludePatterns?: RegExp[]; disableCorrelationHeaders: boolean; + + /** + * The distributed tracing mode to use for this configuration. + * Defaults to AI_AND_W3C. + * 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} + */ distributedTracingMode: DistributedTracingModes; + maxAjaxCallsPerView: number; disableAjaxTracking: boolean; disableFetchTracking: boolean; @@ -41,6 +104,35 @@ export interface ICorrelationConfig { */ ajaxPerfLookupDelay?: number; + /** + * [Optional] Domains to be excluded from correlation headers. + * To override or discard the default, add an array with all domains to be excluded or + * an empty array to the configuration. + * + * @example + * ```ts + * import { ApplicationInsights } from '@microsoft/applicationinsights-web'; + * const appInsights = new ApplicationInsights({ + * config: { + * connectionString: 'InstrumentationKey=YOUR_INSTRUMENTATION_KEY_GOES_HERE', + * extensionConfig: { + * AjaxDependencyPlugin: { + * // Both arrays of strings are used to match the request URL against the + * // current host and the request URL to determine if correlation headers + * // The strings are converted to RegExp objects by translating + * // - `.` to `\\.` (to match a literal dot) + * // - `*` to `.*` (to match any character) + * // - `\` to `\\` (to match a literal slash) + * // All other characters are ignored and passed to the RegExp constructor + * correlationHeaderExcludedDomains: ["test", "*.azure.com", "ignore.microsoft.com"], + * correlationHeaderDomains: ["azure.com", "prefix.bing.com", "*.microsoft.com", "example.com"] + * } + * } + * }); + * appInsights.loadAppInsights(); + * appInsights.trackPageView(); // Manually call trackPageView to establish the current user/session/pageview + * ``` + */ correlationHeaderDomains?: string[]; /** @@ -61,7 +153,7 @@ export interface ICorrelationConfig { * connectionString: 'InstrumentationKey=YOUR_INSTRUMENTATION_KEY_GOES_HERE', * extensions: [dependencyPlugin], * extensionConfig: { - * [dependencyPlugin.identifier]: { + * AjaxDependencyPlugin: { * ignoreHeaders: [ * "Authorization", * "X-API-Key", diff --git a/shared/AppInsightsCommon/src/Interfaces/ITelemetryContext.ts b/shared/AppInsightsCommon/src/Interfaces/ITelemetryContext.ts index a4a991f18..658ff114e 100644 --- a/shared/AppInsightsCommon/src/Interfaces/ITelemetryContext.ts +++ b/shared/AppInsightsCommon/src/Interfaces/ITelemetryContext.ts @@ -35,6 +35,8 @@ export interface ITelemetryContext { /** * The object describing a operation tracked by this object. + * @deprecated Use the core getTraceCtx method instead to get / set the current trace context, this is required to + * support distributed tracing and allows the core to manage the trace context. */ readonly telemetryTrace: ITelemetryTrace; diff --git a/shared/AppInsightsCommon/src/RequestResponseHeaders.ts b/shared/AppInsightsCommon/src/RequestResponseHeaders.ts index c8b659053..2118386ac 100644 --- a/shared/AppInsightsCommon/src/RequestResponseHeaders.ts +++ b/shared/AppInsightsCommon/src/RequestResponseHeaders.ts @@ -55,7 +55,7 @@ export const enum eRequestHeaders { requestContextAppIdFormat = 2, requestIdHeader = 3, traceParentHeader = 4, - traceStateHeader = 5, // currently not used + traceStateHeader = 5, sdkContextHeader = 6, sdkContextHeaderAppIdRequest = 7, requestContextHeaderLowerCase = 8 @@ -68,7 +68,7 @@ export const RequestHeaders = (/* @__PURE__ */ createValueMap { - return trace.name; - }, - setName: (newValue: string): void => { - parentCtx && parentCtx.setName(newValue); - trace.name = newValue; - }, - getTraceId: (): string => { - return trace.traceID; - }, - setTraceId: (newValue: string): void => { - parentCtx && parentCtx.setTraceId(newValue); - if (isValidTraceId(newValue)) { - trace.traceID = newValue - } - }, - getSpanId: (): string => { - return trace.parentID; - }, - setSpanId: (newValue: string): void => { - parentCtx && parentCtx.setSpanId(newValue); - if (isValidSpanId(newValue)) { - trace.parentID = newValue - } - }, - getTraceFlags: (): number => { - return trace.traceFlags; - }, - setTraceFlags: (newTraceFlags?: number): void => { - parentCtx && parentCtx.setTraceFlags(newTraceFlags); - trace.traceFlags = newTraceFlags - } - }; + return traceCtx } diff --git a/shared/AppInsightsCommon/src/applicationinsights-common.ts b/shared/AppInsightsCommon/src/applicationinsights-common.ts index 958c027c3..23902f55e 100644 --- a/shared/AppInsightsCommon/src/applicationinsights-common.ts +++ b/shared/AppInsightsCommon/src/applicationinsights-common.ts @@ -58,7 +58,7 @@ export { ISample } from "./Interfaces/Context/ISample"; export { IOperatingSystem } from "./Interfaces/Context/IOperatingSystem"; export { IPropertiesPlugin } from "./Interfaces/IPropertiesPlugin"; export { IUser, IUserContext } from "./Interfaces/Context/IUser"; -export { ITelemetryTrace, ITraceState } from "./Interfaces/Context/ITelemetryTrace"; +export { ITelemetryTrace } from "./Interfaces/Context/ITelemetryTrace"; export { IRequestContext } from "./Interfaces/IRequestContext"; export { eDistributedTracingModes, DistributedTracingModes, EventPersistence } from "./Enums"; export { stringToBoolOrDefault, msToTimeSpan, getExtensionByName, isCrossOriginError } from "./HelperFuncs"; diff --git a/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts index 87bcfa7f6..0cbe3060a 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/AppInsightsCoreSize.Tests.ts @@ -51,10 +51,10 @@ function _checkSize(checkType: string, maxSize: number, size: number, isNightly: } export class AppInsightsCoreSizeCheck extends AITestClass { - private readonly MAX_RAW_SIZE = 68; - private readonly MAX_BUNDLE_SIZE = 68; - private readonly MAX_RAW_DEFLATE_SIZE = 29; - private readonly MAX_BUNDLE_DEFLATE_SIZE = 29; + 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 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/traceState.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/traceState.Tests.ts new file mode 100644 index 000000000..1b81aa6fc --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/OpenTelemetry/traceState.Tests.ts @@ -0,0 +1,448 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { createOTelTraceState } from "../../../../src/OpenTelemetry/trace/traceState"; +import { strRepeat } from "@nevware21/ts-utils"; + +export class OTelTraceApiTests extends AITestClass { + + public testInitialize() { + super.testInitialize(); + } + + public testCleanup() { + super.testCleanup(); + } + + public registerTests() { + + this.testCase({ + name: "TraceState: serialize", + test: () => { + const traceState = createOTelTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle empty string", + test: () => { + const traceState = createOTelTraceState(""); + Assert.equal(traceState.serialize(), ""); + } + }); + + this.testCase({ + name: "TraceState: handle null", + test: () => { + const traceState = createOTelTraceState(null as any); + Assert.equal(traceState.serialize(), ""); + } + }); + + this.testCase({ + name: "TraceState: handle undefined", + test: () => { + const traceState = createOTelTraceState(undefined); + Assert.equal(traceState.serialize(), ""); + } + }); + + this.testCase({ + name: "TraceState: handle invalid input", + test: () => { + const traceState = createOTelTraceState({} as any); + Assert.equal(traceState.serialize(), ""); + } + }); + + this.testCase({ + name: "TraceState: new / updated keys are added to the front", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", "4"); + Assert.equal(traceState.serialize(), "d=4,a=1,b=2,c=3"); + + traceState = traceState.set("a", "5"); + Assert.equal(traceState.serialize(), "a=5,d=4,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: must create new instances for each state", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + let traceState2 = createOTelTraceState(traceState.serialize()); + traceState2 = traceState2.set("d", "4"); + + Assert.notEqual(traceState, traceState2); + Assert.notDeepEqual(traceState, traceState2); + + Assert.equal(traceState.serialize(), "a=1,b=2,c=3", "Actual: " + traceState.serialize() + " expected a=1,b=2,c=3"); + Assert.equal(traceState2.serialize(), "d=4,a=1,b=2,c=3", "Actual: " + traceState2.serialize() + " expected d=4,a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: unset", + test: () => { + let traceState = createOTelTraceState("a=4,b=5,c=6"); + traceState = traceState.unset("b"); + Assert.equal(traceState.serialize(), "a=4,c=6"); + + traceState = traceState.unset("a"); + Assert.equal(traceState.serialize(), "c=6"); + } + }); + + this.testCase({ + name: "TraceState: get", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), "2"); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.get("d"), undefined); + } + }); + + this.testCase({ + name: "TraceState: serialize with spaces", + test: () => { + const traceState = createOTelTraceState("a=1, b=2, c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: serialize with tabs", + test: () => { + const traceState = createOTelTraceState("a=1\t,b=2\t,c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: serialize with newlines", + test: () => { + const traceState = createOTelTraceState("a=1\n,b=2\n,c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: serialize with multiple commas", + test: () => { + const traceState = createOTelTraceState("a=1,,b=2,,c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: serialize with multiple equals", + test: () => { + const traceState = createOTelTraceState("a==1,b==2,c==3"); + Assert.equal(traceState.serialize(), ""); + } + }); + + this.testCase({ + name: "TraceState: serialize with multiple spaces", + test: () => { + const traceState = createOTelTraceState("a=1 , b=2 , c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: serialize with multiple tabs", + test: () => { + const traceState = createOTelTraceState("a=1\t\t,b=2\t\t,c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: serialize with multiple newlines", + test: () => { + const traceState = createOTelTraceState("a=1\n\n,b=2\n\n,c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: serialize with multiple commas and spaces", + test: () => { + const traceState = createOTelTraceState("a=1, ,b=2, ,c=3"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle unsetting non-existent keys", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.unset("d"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting empty key", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("", "4"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting empty value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", ""); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting empty string value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", " "); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting empty key and value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("", ""); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting null key", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set(null as any, "4"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting null value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", null as any); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting null string value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", "null"); + Assert.equal(traceState.serialize(), "d=null,a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting null key and value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set(null as any, null as any); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting undefined key", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set(undefined as any, "4"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + this.testCase({ + name: "TraceState: handle setting undefined value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", undefined as any); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting undefined key and value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set(undefined as any, undefined as any); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting invalid key", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set({} as any, "4"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting invalid value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", {} as any); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting invalid key and value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set({} as any, {} as any); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting invalid key and valid value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set({} as any, "4"); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle setting valid key and invalid value", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", {} as any); + Assert.equal(traceState.serialize(), "a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "TraceState: handle dropping states when the max number of members limit is reached", + test: () => { + let traceState = createOTelTraceState("a=1,b=2,c=3"); + traceState = traceState.set("d", "4"); + traceState = traceState.set("e", "5"); + traceState = traceState.set("f", "6"); + traceState = traceState.set("g", "7"); + traceState = traceState.set("h", "8"); + traceState = traceState.set("i", "9"); + traceState = traceState.set("j", "10"); + traceState = traceState.set("k", "11"); + traceState = traceState.set("l", "12"); + traceState = traceState.set("m", "13"); + traceState = traceState.set("n", "14"); + traceState = traceState.set("o", "15"); + traceState = traceState.set("p", "16"); + traceState = traceState.set("q", "17"); + traceState = traceState.set("r", "18"); + traceState = traceState.set("s", "19"); + traceState = traceState.set("t", "20"); + traceState = traceState.set("u", "21"); + traceState = traceState.set("v", "22"); + traceState = traceState.set("w", "23"); + traceState = traceState.set("x", "24"); + traceState = traceState.set("y", "25"); + traceState = traceState.set("z", "26"); + traceState = traceState.set("aa", "27"); + traceState = traceState.set("ab", "28"); + traceState = traceState.set("ac", "29"); + traceState = traceState.set("ad", "30"); + traceState = traceState.set("ae", "31"); + traceState = traceState.set("af", "32"); + Assert.equal(traceState.serialize(), "af=32,ae=31,ad=30,ac=29,ab=28,aa=27,z=26,y=25,x=24,w=23,v=22,u=21,t=20,s=19,r=18,q=17,p=16,o=15,n=14,m=13,l=12,k=11,j=10,i=9,h=8,g=7,f=6,e=5,d=4,a=1,b=2,c=3"); + traceState = traceState.set("ag", "33"); + Assert.equal(traceState.serialize(), "ag=33,af=32,ae=31,ad=30,ac=29,ab=28,aa=27,z=26,y=25,x=24,w=23,v=22,u=21,t=20,s=19,r=18,q=17,p=16,o=15,n=14,m=13,l=12,k=11,j=10,i=9,h=8,g=7,f=6,e=5,d=4,a=1,b=2"); + } + }); + + this.testCase({ + name: "TraceState: drop states when the items are too long", + test: () => { + const traceState = createOTelTraceState("a=" + strRepeat("b", 512)); + Assert.equal(traceState.get("a"), undefined); + Assert.equal(traceState.serialize(), ""); + } + }); + + this.testCase({ + name: "TraceState: drop items that are invalid", + test: () => { + const traceState = createOTelTraceState("a=1,b,c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.serialize(), "a=1,c=3"); + } + }); + + this.testCase({ + name: "TraceState: drop items that are invalid with spaces", + test: () => { + const traceState = createOTelTraceState("a=1, b, c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.serialize(), "a=1,c=3"); + } + }); + + this.testCase({ + name: "TraceState: drop items that are invalid with tabs", + test: () => { + const traceState = createOTelTraceState("a=1\t,b\t,c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.serialize(), "a=1,c=3"); + } + }); + + this.testCase({ + name: "TraceState: drop items that have a single value with an '=' sign", + test: () => { + const traceState = createOTelTraceState("a=1,b=2=,c=3,d="); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.get("d"), undefined); + Assert.equal(traceState.serialize(), "a=1,c=3"); + } + }); + + this.testCase({ + name: "TraceState: must handle valid state key ranges", + test: () => { + const traceState = createOTelTraceState("a-b=1,c/d=2,e_f=3,g*h=4"); + Assert.equal(traceState.get("a-b"), "1"); + Assert.equal(traceState.get("c/d"), "2"); + Assert.equal(traceState.get("e_f"), "3"); + Assert.equal(traceState.get("g*h"), "4"); + Assert.equal(traceState.serialize(), "a-b=1,c/d=2,e_f=3,g*h=4"); + } + }); + + this.testCase({ + name: "TraceState: handle values with embedded spaces", + test: () => { + const traceState = createOTelTraceState("a=1 b,c=2 d,e=3 f"); + Assert.equal(traceState.get("a"), "1 b"); + Assert.equal(traceState.get("c"), "2 d"); + Assert.equal(traceState.get("e"), "3 f"); + Assert.equal(traceState.serialize(), "a=1 b,c=2 d,e=3 f"); + } + }); + + } +} \ No newline at end of file diff --git a/shared/AppInsightsCore/Tests/Unit/src/W3TraceState.Tests.ts b/shared/AppInsightsCore/Tests/Unit/src/W3TraceState.Tests.ts new file mode 100644 index 000000000..3a6a05270 --- /dev/null +++ b/shared/AppInsightsCore/Tests/Unit/src/W3TraceState.Tests.ts @@ -0,0 +1,1606 @@ +import { Assert, AITestClass } from "@microsoft/ai-test-framework"; +import { asString, strRepeat } from "@nevware21/ts-utils"; +import { createW3cTraceState, isW3cTraceState, snapshotW3cTraceState } from "../../../src/JavaScriptSDK/W3cTraceState"; + +export class W3cTraceStateTests extends AITestClass { + + public testInitialize() { + super.testInitialize(); + } + + public testCleanup() { + super.testCleanup(); + } + + public registerTests() { + + this.testCase({ + name: "W3cTraceState: default", + test: () => { + const traceState = createW3cTraceState(); + Assert.equal(traceState.keys.length, 0); + Assert.equal(traceState.hdrs().length, 0); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.isEmpty, true, "Default trace state should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: handle empty string", + test: () => { + const traceState = createW3cTraceState(""); + Assert.equal(traceState.keys.length, 0); + Assert.equal(traceState.hdrs().length, 0); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.isEmpty, true, "Empty string trace state should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: handle null", + test: () => { + const traceState = createW3cTraceState(null as any); + Assert.equal(traceState.keys.length, 0); + Assert.equal(traceState.hdrs().length, 0); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.isEmpty, true, "Null trace state should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: handle undefined", + test: () => { + const traceState = createW3cTraceState(undefined); + Assert.equal(traceState.keys.length, 0); + Assert.equal(traceState.hdrs().length, 0); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.isEmpty, true, "Undefined trace state should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: toString", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.isEmpty, false, "Trace state with values should not be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: handle invalid input", + test: () => { + const traceState = createW3cTraceState({} as any); + Assert.equal(traceState.keys.length, 0); + Assert.equal(traceState.hdrs().length, 0); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.isEmpty, true, "Invalid trace state should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: new / updated keys are added to the front", + test: () => { + let traceState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + + traceState.set("d", "4"); + Assert.equal(asString(traceState), "d=4,a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 4); + Assert.deepEqual(["d", "a", "b", "c"], traceState.keys); + + traceState.set("a", "5"); + Assert.equal(asString(traceState), "a=5,d=4,b=2,c=3"); + Assert.equal(traceState.keys.length, 4); + Assert.deepEqual(["a", "d", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: must create new instances for each state", + test: () => { + let traceState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + + let traceState2 = createW3cTraceState(asString(traceState)); + Assert.equal(traceState2.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState2.keys); + traceState2.set("d", "4"); + Assert.equal(traceState2.keys.length, 4); + Assert.deepEqual(["d", "a", "b", "c"], traceState2.keys); + + Assert.notEqual(traceState, traceState2); + Assert.notDeepEqual(traceState, traceState2); + + Assert.equal(asString(traceState), "a=1,b=2,c=3", "Actual: " + asString(traceState) + " expected a=1,b=2,c=3"); + Assert.equal(asString(traceState2), "d=4,a=1,b=2,c=3", "Actual: " + asString(traceState2) + " expected d=4,a=1,b=2,c=3"); + } + }); + + this.testCase({ + name: "W3cTraceState: del", + test: () => { + let traceState = createW3cTraceState("a=4,b=5,c=6"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty initially"); + + traceState.del("b"); + Assert.equal(asString(traceState), "a=4,c=6"); + Assert.equal(traceState.keys.length, 2); + Assert.deepEqual(["a", "c"], traceState.keys); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty after deleting one key"); + + traceState.del("a"); + Assert.equal(asString(traceState), "c=6"); + Assert.equal(traceState.keys.length, 1); + Assert.deepEqual(["c"], traceState.keys); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty with one key remaining"); + + traceState.del("c"); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.keys.length, 0); + Assert.deepEqual([], traceState.keys); + Assert.equal(traceState.isEmpty, true, "Trace state should be empty after deleting all keys"); + } + }); + + this.testCase({ + name: "W3cTraceState: get", + test: () => { + let traceState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), "2"); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.get("d"), undefined); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with spaces", + test: () => { + const traceState = createW3cTraceState("a=1, b=2, c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with tabs", + test: () => { + const traceState = createW3cTraceState("a=1\t,b=2\t,c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with newlines", + test: () => { + const traceState = createW3cTraceState("a=1\n,b=2\n,c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with multiple commas", + test: () => { + const traceState = createW3cTraceState("a=1,,b=2,,c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with multiple equals", + test: () => { + const traceState = createW3cTraceState("a==1,b==2,c==3"); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.keys.length, 0); + Assert.deepEqual([], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with multiple spaces", + test: () => { + const traceState = createW3cTraceState("a=1 , b=2 , c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with multiple tabs", + test: () => { + const traceState = createW3cTraceState("a=1\t\t,b=2\t\t,c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with multiple newlines", + test: () => { + const traceState = createW3cTraceState("a=1\n\n,b=2\n\n,c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: decode with multiple commas and spaces", + test: () => { + const traceState = createW3cTraceState("a=1, ,b=2, ,c=3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle unsetting non-existent keys", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.del("d"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting empty key", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("", "4"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting empty value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", ""); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting empty string value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", " "); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting empty key and value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("", ""); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting null key", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set(null as any, "4"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting null value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", null as any); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting null string value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", "null"); + Assert.equal(asString(traceState), "d=null,a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 4); + Assert.deepEqual(["d", "a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting null key and value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set(null as any, null as any); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting undefined key", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set(undefined as any, "4"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + this.testCase({ + name: "W3cTraceState: handle setting undefined value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", undefined as any); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting undefined key and value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set(undefined as any, undefined as any); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting invalid key", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set({} as any, "4"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting invalid value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", {} as any); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting invalid key and value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set({} as any, {} as any); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting invalid key and valid value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set({} as any, "4"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle setting valid key and invalid value", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", {} as any); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle dropping states when the max number of members limit is reached", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + traceState.set("d", "4"); + traceState.set("e", "5"); + traceState.set("f", "6"); + traceState.set("g", "7"); + traceState.set("h", "8"); + traceState.set("i", "9"); + traceState.set("j", "10"); + traceState.set("k", "11"); + traceState.set("l", "12"); + traceState.set("m", "13"); + traceState.set("n", "14"); + traceState.set("o", "15"); + traceState.set("p", "16"); + traceState.set("q", "17"); + traceState.set("r", "18"); + traceState.set("s", "19"); + traceState.set("t", "20"); + traceState.set("u", "21"); + traceState.set("v", "22"); + traceState.set("w", "23"); + traceState.set("x", "24"); + traceState.set("y", "25"); + traceState.set("z", "26"); + traceState.set("aa", "27"); + traceState.set("ab", "28"); + traceState.set("ac", "29"); + traceState.set("ad", "30"); + traceState.set("ae", "31"); + traceState.set("af", "32"); + Assert.equal(asString(traceState), "af=32,ae=31,ad=30,ac=29,ab=28,aa=27,z=26,y=25,x=24,w=23,v=22,u=21,t=20,s=19,r=18,q=17,p=16,o=15,n=14,m=13,l=12,k=11,j=10,i=9,h=8,g=7,f=6,e=5,d=4,a=1,b=2,c=3"); + traceState.set("ag", "33"); + Assert.equal(asString(traceState), "ag=33,af=32,ae=31,ad=30,ac=29,ab=28,aa=27,z=26,y=25,x=24,w=23,v=22,u=21,t=20,s=19,r=18,q=17,p=16,o=15,n=14,m=13,l=12,k=11,j=10,i=9,h=8,g=7,f=6,e=5,d=4,a=1,b=2"); + Assert.equal(traceState.keys.length, 33); + Assert.deepEqual(["ag","af","ae","ad","ac","ab","aa","z","y","x","w","v","u","t","s","r","q","p","o","n","m","l","k","j","i","h","g","f","e","d","a","b","c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: drop states when the items are too long", + test: () => { + const traceState = createW3cTraceState("a=" + strRepeat("b", 512)); + Assert.equal(traceState.get("a"), undefined); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.keys.length, 0); + Assert.deepEqual([], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: drop items that are invalid", + test: () => { + const traceState = createW3cTraceState("a=1,b,c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,c=3"); + Assert.equal(traceState.keys.length, 2); + Assert.deepEqual(["a", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: drop items that are invalid with spaces", + test: () => { + const traceState = createW3cTraceState("a=1, b, c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,c=3"); + Assert.equal(traceState.keys.length, 2); + Assert.deepEqual(["a", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: drop items that are invalid with tabs", + test: () => { + const traceState = createW3cTraceState("a=1\t,b\t,c=3"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,c=3"); + Assert.equal(traceState.keys.length, 2); + Assert.deepEqual(["a", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: drop items that have a single value with an '=' sign", + test: () => { + const traceState = createW3cTraceState("a=1,b=2=,c=3,d="); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.get("d"), undefined); + Assert.equal(asString(traceState), "a=1,c=3"); + Assert.equal(traceState.keys.length, 2); + Assert.deepEqual(["a", "c"], traceState.keys); + Assert.equal(traceState.isEmpty, false, "Trace state with valid keys should not be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: isEmpty with all invalid items", + test: () => { + const traceState = createW3cTraceState("b=2=,d="); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("d"), undefined); + Assert.equal(asString(traceState), ""); + Assert.equal(traceState.keys.length, 0); + Assert.deepEqual([], traceState.keys); + Assert.equal(traceState.isEmpty, true, "Trace state with only invalid items should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: must handle valid state key ranges", + test: () => { + const traceState = createW3cTraceState("a-b=1,c/d=2,e_f=3,g*h=4"); + Assert.equal(traceState.get("a-b"), "1"); + Assert.equal(traceState.get("c/d"), "2"); + Assert.equal(traceState.get("e_f"), "3"); + Assert.equal(traceState.get("g*h"), "4"); + Assert.equal(asString(traceState), "a-b=1,c/d=2,e_f=3,g*h=4"); + Assert.equal(traceState.keys.length, 4); + Assert.deepEqual(["a-b", "c/d", "e_f", "g*h"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle values with embedded spaces", + test: () => { + const traceState = createW3cTraceState("a=1 b,c=2 d,e=3 f"); + Assert.equal(traceState.get("a"), "1 b"); + Assert.equal(traceState.get("c"), "2 d"); + Assert.equal(traceState.get("e"), "3 f"); + Assert.equal(asString(traceState), "a=1 b,c=2 d,e=3 f"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "c", "e"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle inherited state values with no new values", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(parentState.get("a"), "1"); + Assert.equal(parentState.get("b"), "2"); + Assert.equal(parentState.get("c"), "3"); + Assert.equal(asString(parentState), "a=1,b=2,c=3"); + Assert.equal(parentState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], parentState.keys); + + const traceState = createW3cTraceState("", parentState); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), "2"); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle inherited state values with new values", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(parentState.get("a"), "1"); + Assert.equal(parentState.get("b"), "2"); + Assert.equal(parentState.get("c"), "3"); + Assert.equal(asString(parentState), "a=1,b=2,c=3"); + Assert.equal(parentState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], parentState.keys); + + const traceState = createW3cTraceState("d=4,e=5,f=6", parentState); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), "2"); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.get("d"), "4"); + Assert.equal(traceState.get("e"), "5"); + Assert.equal(traceState.get("f"), "6"); + Assert.equal(asString(traceState), "d=4,e=5,f=6,a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 6); + Assert.deepEqual(["d", "e", "f", "a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle inherited state values with new values that are too long", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(parentState.get("a"), "1"); + Assert.equal(parentState.get("b"), "2"); + Assert.equal(parentState.get("c"), "3"); + Assert.equal(asString(parentState), "a=1,b=2,c=3"); + Assert.equal(parentState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], parentState.keys); + + const traceState = createW3cTraceState("d=" + strRepeat("e", 512), parentState); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), "2"); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(traceState.get("d"), undefined); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle deleting an inherited key in the child", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(parentState.get("a"), "1"); + Assert.equal(parentState.get("b"), "2"); + Assert.equal(parentState.get("c"), "3"); + Assert.equal(asString(parentState), "a=1,b=2,c=3"); + Assert.equal(parentState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], parentState.keys); + + const traceState = createW3cTraceState("", parentState); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), "2"); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + + traceState.del("b"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,c=3"); + Assert.equal(traceState.keys.length, 2); + Assert.deepEqual(["a", "c"], traceState.keys); + + // Should not affect the original state + Assert.equal(parentState.get("a"), "1"); + Assert.equal(parentState.get("b"), "2"); + Assert.equal(parentState.get("c"), "3"); + Assert.equal(asString(parentState), "a=1,b=2,c=3"); + Assert.equal(parentState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], parentState.keys); + } + }); + + this.testCase({ + name: "W3cTraceState: handle deleting an inherited key in the parent", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(parentState.get("a"), "1"); + Assert.equal(parentState.get("b"), "2"); + Assert.equal(parentState.get("c"), "3"); + Assert.equal(asString(parentState), "a=1,b=2,c=3"); + Assert.equal(parentState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], parentState.keys); + + const traceState = createW3cTraceState("", parentState); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), "2"); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,b=2,c=3"); + Assert.equal(traceState.keys.length, 3); + Assert.deepEqual(["a", "b", "c"], traceState.keys); + + parentState.del("b"); + Assert.equal(traceState.get("a"), "1"); + Assert.equal(traceState.get("b"), undefined); + Assert.equal(traceState.get("c"), "3"); + Assert.equal(asString(traceState), "a=1,c=3"); + Assert.equal(traceState.keys.length, 2); + Assert.deepEqual(["a", "c"], traceState.keys); + + // Should not affect the original state + Assert.equal(parentState.get("a"), "1"); + Assert.equal(parentState.get("b"), undefined); + Assert.equal(parentState.get("c"), "3"); + Assert.equal(asString(parentState), "a=1,c=3"); + Assert.equal(parentState.keys.length, 2); + Assert.deepEqual(["a", "c"], parentState.keys); + } + }); + + // isEmpty property specific tests + this.testCase({ + name: "W3cTraceState: isEmpty with trace state values", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.isEmpty, false, "Trace state with values should not be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: isEmpty after deleting all keys", + test: () => { + const traceState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(traceState.isEmpty, false, "Trace state with values should not be empty"); + + traceState.del("a"); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty after deleting one key"); + + traceState.del("b"); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty after deleting two keys"); + + traceState.del("c"); + Assert.equal(traceState.isEmpty, true, "Trace state should be empty after deleting all keys"); + } + }); + + this.testCase({ + name: "W3cTraceState: isEmpty with parent state", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + Assert.equal(parentState.isEmpty, false, "Parent trace state with values should not be empty"); + + const childState = createW3cTraceState("", parentState); + Assert.equal(childState.isEmpty, false, "Child trace state with parent values should not be empty"); + + const emptyChildState = createW3cTraceState("", createW3cTraceState()); + Assert.equal(emptyChildState.isEmpty, true, "Child trace state with empty parent should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: isEmpty after deleting inherited keys", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + const childState = createW3cTraceState("", parentState); + Assert.equal(childState.isEmpty, false, "Child trace state with parent values should not be empty"); + + childState.del("a"); + Assert.equal(childState.isEmpty, false, "Child trace state should not be empty after deleting one key"); + + childState.del("b"); + Assert.equal(childState.isEmpty, false, "Child trace state should not be empty after deleting two keys"); + + childState.del("c"); + Assert.equal(childState.isEmpty, true, "Child trace state should be empty after deleting all keys"); + + // Parent state should remain unchanged + Assert.equal(parentState.isEmpty, false, "Parent trace state should remain unchanged"); + } + }); + + this.testCase({ + name: "W3cTraceState: isEmpty after parent deletes keys", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + const childState = createW3cTraceState("", parentState); + Assert.equal(childState.isEmpty, false, "Child trace state with parent values should not be empty"); + + parentState.del("a"); + Assert.equal(childState.isEmpty, false, "Child trace state should not be empty after parent deletes one key"); + + parentState.del("b"); + Assert.equal(childState.isEmpty, false, "Child trace state should not be empty after parent deletes two keys"); + + parentState.del("c"); + Assert.equal(childState.isEmpty, true, "Child trace state should be empty after parent deletes all keys"); + } + }); + + this.testCase({ + name: "W3cTraceState: isEmpty with mixed operations", + test: () => { + const traceState = createW3cTraceState(); + Assert.equal(traceState.isEmpty, true, "Empty trace state should be empty"); + + traceState.set("a", "1"); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty after adding a key"); + + traceState.del("a"); + Assert.equal(traceState.isEmpty, true, "Trace state should be empty after deleting the key"); + + traceState.set("b", "2"); + traceState.set("c", "3"); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty after adding multiple keys"); + + traceState.del("b"); + Assert.equal(traceState.isEmpty, false, "Trace state should not be empty with remaining keys"); + + traceState.del("c"); + Assert.equal(traceState.isEmpty, true, "Trace state should be empty after deleting all keys"); + } + }); + + this.testCase({ + name: "W3cTraceState: isEmpty with changing parent values", + test: () => { + const parentState = createW3cTraceState(); + const childState = createW3cTraceState("", parentState); + Assert.equal(parentState.isEmpty, true, "Empty parent trace state should be empty"); + Assert.equal(childState.isEmpty, true, "Child of empty parent should be empty"); + + parentState.set("a", "1"); + Assert.equal(parentState.isEmpty, false, "Parent trace state should not be empty after adding a key"); + Assert.equal(childState.isEmpty, false, "Child trace state should not be empty after parent adds a key"); + + childState.del("a"); + Assert.equal(parentState.isEmpty, false, "Parent trace state should remain unchanged after child deletes key"); + Assert.equal(childState.isEmpty, true, "Child trace state should be empty after deleting parent's key"); + + childState.set("b", "2"); + Assert.equal(childState.isEmpty, false, "Child trace state should not be empty after adding own key"); + + parentState.del("a"); + Assert.equal(parentState.isEmpty, true, "Parent trace state should be empty after deleting its only key"); + Assert.equal(childState.isEmpty, false, "Child trace state should not be empty with its own keys"); + } + }); + + // Tests for the child() function + this.testCase({ + name: "W3cTraceState: child() creates a new child trace state instance", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + const childState = parentState.child(); + + Assert.notEqual(parentState, childState, "Child should be a new instance"); + Assert.equal(childState.get("a"), "1", "Child should inherit parent values"); + Assert.equal(childState.get("b"), "2", "Child should inherit parent values"); + Assert.equal(childState.get("c"), "3", "Child should inherit parent values"); + Assert.equal(asString(childState), "a=1,b=2,c=3", "Child should have same string representation as parent"); + Assert.equal(childState.keys.length, 3, "Child should have same number of keys as parent"); + Assert.deepEqual(childState.keys, ["a", "b", "c"], "Child should have same keys as parent"); + Assert.equal(childState.isEmpty, false, "Child should not be empty if parent is not empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() from empty parent", + test: () => { + const parentState = createW3cTraceState(); + const childState = parentState.child(); + + Assert.notEqual(parentState, childState, "Child should be a new instance"); + Assert.equal(childState.keys.length, 0, "Child of empty parent should have no keys"); + Assert.equal(asString(childState), "", "Child of empty parent should have empty string representation"); + Assert.equal(childState.isEmpty, true, "Child of empty parent should be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with modifications to parent", + test: () => { + const parentState = createW3cTraceState("a=1,b=2"); + const childState = parentState.child(); + + Assert.equal(asString(childState), "a=1,b=2", "Child should initially match parent"); + + // Modify parent after creating child + parentState.set("c", "3"); + Assert.equal(asString(parentState), "c=3,a=1,b=2", "Parent should have new value"); + Assert.equal(asString(childState), "c=3,a=1,b=2", "Child should reflect parent changes"); + Assert.equal(childState.get("c"), "3", "Child should get updated value from parent"); + + // Delete from parent + parentState.del("a"); + Assert.equal(asString(parentState), "c=3,b=2", "Parent should have key removed"); + Assert.equal(asString(childState), "c=3,b=2", "Child should reflect parent deletions"); + Assert.equal(childState.get("a"), undefined, "Child should not have deleted key"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with modifications to child", + test: () => { + const parentState = createW3cTraceState("a=1,b=2"); + const childState = parentState.child(); + + // Modify child + childState.set("c", "3"); + Assert.equal(asString(childState), "c=3,a=1,b=2", "Child should have new value"); + Assert.equal(asString(parentState), "a=1,b=2", "Parent should not be affected by child changes"); + Assert.equal(parentState.get("c"), undefined, "Parent should not have child's new key"); + + // Modify existing value in child + childState.set("a", "4"); + Assert.equal(asString(childState), "a=4,c=3,b=2", "Child should have updated value"); + Assert.equal(asString(parentState), "a=1,b=2", "Parent should not be affected by child modifications"); + Assert.equal(parentState.get("a"), "1", "Parent should keep original value"); + Assert.equal(childState.get("a"), "4", "Child should have updated value"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with deletions in child", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + const childState = parentState.child(); + + // Delete key in child + childState.del("b"); + Assert.equal(asString(childState), "a=1,c=3", "Child should have key removed"); + Assert.equal(asString(parentState), "a=1,b=2,c=3", "Parent should not be affected by child deletions"); + Assert.equal(parentState.get("b"), "2", "Parent should keep deleted key"); + Assert.equal(childState.get("b"), undefined, "Child should not have deleted key"); + + // Delete all keys in child + childState.del("a"); + childState.del("c"); + Assert.equal(asString(childState), "", "Child should have all keys removed"); + Assert.equal(childState.isEmpty, true, "Child should be empty after deleting all keys"); + Assert.equal(asString(parentState), "a=1,b=2,c=3", "Parent should remain unchanged"); + Assert.equal(parentState.isEmpty, false, "Parent should not be empty"); + } + }); + + this.testCase({ + name: "W3cTraceState: nested children creation", + test: () => { + const parentState = createW3cTraceState("a=1,b=2"); + const childState = parentState.child(); + const grandchildState = childState.child(); + + Assert.notEqual(parentState, childState, "Child should be a new instance"); + Assert.notEqual(childState, grandchildState, "Grandchild should be a new instance"); + Assert.notEqual(parentState, grandchildState, "Grandchild should be a new instance different from parent"); + + Assert.equal(asString(grandchildState), "a=1,b=2", "Grandchild should inherit all values"); + + // Modify at each level + parentState.set("p", "parent"); + childState.set("c", "child"); + grandchildState.set("g", "grandchild"); + + Assert.equal(asString(parentState), "p=parent,a=1,b=2", "Parent should have its own values"); + Assert.equal(asString(childState), "c=child,p=parent,a=1,b=2", "Child should have its values and parent's"); + Assert.equal(asString(grandchildState), "g=grandchild,c=child,p=parent,a=1,b=2", "Grandchild should have all values"); + + // Override values at different levels + childState.set("a", "child-a"); + grandchildState.set("c", "grandchild-c"); + + Assert.equal(parentState.get("a"), "1", "Parent should keep original value"); + Assert.equal(childState.get("a"), "child-a", "Child should have overridden value"); + Assert.equal(grandchildState.get("a"), "child-a", "Grandchild should inherit child's value"); + Assert.equal(childState.get("c"), "child", "Child should keep its value"); + Assert.equal(grandchildState.get("c"), "grandchild-c", "Grandchild should have overridden value"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with modifications affecting isEmpty", + test: () => { + const parentState = createW3cTraceState("a=1,b=2"); + const childState = parentState.child(); + + Assert.equal(parentState.isEmpty, false, "Parent should not be empty"); + Assert.equal(childState.isEmpty, false, "Child should not be empty"); + + // Delete all parent keys from child + childState.del("a"); + childState.del("b"); + + Assert.equal(parentState.isEmpty, false, "Parent should still not be empty"); + Assert.equal(childState.isEmpty, true, "Child should be empty after deleting all keys"); + + // Add key to parent + parentState.set("c", "3"); + Assert.equal(childState.isEmpty, false, "Child should not be empty after parent adds key"); + Assert.equal(childState.get("c"), "3", "Child should have parent's new key"); + + // Delete from parent + parentState.del("a"); + parentState.del("b"); + parentState.del("c"); + + Assert.equal(parentState.isEmpty, true, "Parent should be empty after deleting all keys"); + Assert.equal(childState.isEmpty, true, "Child should be empty when parent is empty and child has deleted parent keys"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() preserves key ordering", + test: () => { + const parentState = createW3cTraceState("a=1,b=2,c=3"); + const childState = parentState.child(); + + // Add keys to child, which should go to the front + childState.set("d", "4"); + childState.set("e", "5"); + + Assert.deepEqual(childState.keys, ["e", "d", "a", "b", "c"], "Child should have keys in correct order"); + + // Update existing key in child, which should move to front + childState.set("b", "new-b"); + + Assert.deepEqual(childState.keys, ["b", "e", "d", "a", "c"], "Updated key should move to front"); + Assert.equal(childState.get("b"), "new-b", "Should get updated value"); + + // Add new key to parent, should be inherited by child + parentState.set("f", "6"); + + // Child keys should include parent's new key but maintain child's order + Assert.deepEqual(childState.keys, ["b", "e", "d", "f", "a", "c"], "Child should include parent's new key"); + + // Update existing key in parent, which should be reflected in child unless overridden + parentState.set("a", "new-a"); + + Assert.equal(childState.get("a"), "new-a", "Child should see parent's updated value"); + + // Override parent key that was already overridden + childState.set("a", "child-a"); + + Assert.equal(parentState.get("a"), "new-a", "Parent should keep its value"); + Assert.equal(childState.get("a"), "child-a", "Child should have its own value"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() handles hdrs() correctly", + test: () => { + const parentState = createW3cTraceState("a=1,b=2"); + const childState = parentState.child(); + + Assert.deepEqual(childState.hdrs(), ["a=1,b=2"], "Child headers should match parent initially"); + + childState.set("c", "3"); + Assert.deepEqual(childState.hdrs(), ["c=3,a=1,b=2"], "Child headers should include own values"); + Assert.deepEqual(parentState.hdrs(), ["a=1,b=2"], "Parent headers should remain unchanged"); + + // Test with maxHeaders parameter + const longParent = createW3cTraceState("a=" + strRepeat("x", 250) + ",b=" + strRepeat("y", 250)); + const longChild = longParent.child(); + longChild.set("c", strRepeat("z", 250)); + + // Should split into multiple headers due to length + Assert.equal(longChild.hdrs().length > 1, true, "Long values should split into multiple headers"); + + // Test with maxKeys parameter + Assert.deepEqual(longChild.hdrs(undefined, 1), ["c=" + strRepeat("z", 250)], "Should respect maxKeys parameter"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with invalid values in parent", + test: () => { + // Create parent with some invalid entries that will be filtered out + const parentState = createW3cTraceState("a=1,invalid,b=2,c="); + Assert.equal(asString(parentState), "a=1,b=2", "Parent should only have valid entries"); + + const childState = parentState.child(); + Assert.equal(asString(childState), "a=1,b=2", "Child should inherit only valid entries"); + + // Add an invalid entry to parent after child creation + parentState.set("d", ""); + Assert.equal(asString(parentState), "a=1,b=2", "Parent should not add invalid entry"); + Assert.equal(asString(childState), "a=1,b=2", "Child should reflect parent's valid state"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with multiple children from same parent", + test: () => { + const parentState = createW3cTraceState("a=1,b=2"); + + // Create multiple children + const child1 = parentState.child(); + const child2 = parentState.child(); + const child3 = parentState.child(); + + Assert.notEqual(child1, child2, "Each child should be a distinct instance"); + Assert.notEqual(child2, child3, "Each child should be a distinct instance"); + + // Modify each child differently + child1.set("c", "3"); + child2.set("d", "4"); + child3.set("e", "5"); + + // Each child should have its own modifications plus parent's values + Assert.equal(asString(child1), "c=3,a=1,b=2", "Child 1 should have its own changes"); + Assert.equal(asString(child2), "d=4,a=1,b=2", "Child 2 should have its own changes"); + Assert.equal(asString(child3), "e=5,a=1,b=2", "Child 3 should have its own changes"); + + // Modify parent, all children should reflect the change + parentState.set("f", "6"); + + Assert.equal(asString(child1), "c=3,f=6,a=1,b=2", "Child 1 should see parent's new value"); + Assert.equal(asString(child2), "d=4,f=6,a=1,b=2", "Child 2 should see parent's new value"); + Assert.equal(asString(child3), "e=5,f=6,a=1,b=2", "Child 3 should see parent's new value"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with limit on trace state members", + test: () => { + // Create a parent with more than the maximum allowed entries for hdrs + const parentState = createW3cTraceState(); + for (let i = 0; i < 40; i++) { + parentState.set("key" + i, "value" + i); + } + + // In memory, we can exceed 32 keys + Assert.equal(parentState.keys.length, 40, "Parent should have all 40 keys in memory"); + Assert.equal(parentState.get("key0"), "value0", "First key should be accessible"); + Assert.equal(parentState.get("key39"), "value39", "Last key should be accessible"); + + // When converted to headers, it's limited to 32 keys + const headers = parentState.hdrs(); + let headersStr = headers.join(","); + // First keys (0-7) should not be in headers as they're the oldest + Assert.equal(headersStr.indexOf("key0=value0"), -1, "Oldest keys should not be in headers"); + Assert.equal(headersStr.indexOf("key7=value7"), -1, "Oldest keys should not be in headers"); + // Most recent keys (8-39) should be in headers + Assert.notEqual(headersStr.indexOf("key8=value8"), -1, "More recent keys should be in headers"); + Assert.notEqual(headersStr.indexOf("key39=value39"), -1, "Most recent keys should be in headers"); + + // Create a child state + const childState = parentState.child(); + + // Child should inherit all keys in memory + Assert.equal(childState.keys.length, 40, "Child should inherit all keys in memory"); + Assert.equal(childState.get("key0"), "value0", "Child should have access to all parent keys"); + Assert.equal(childState.get("key39"), "value39", "Child should have access to all parent keys"); + + // Add more keys to child + for (let i = 0; i < 10; i++) { + childState.set("child-key" + i, "child-value" + i); + } + + // In memory, child should have all keys + Assert.equal(childState.keys.length, 50, "Child should have all 50 keys in memory"); + + // When converted to headers and back, we should only get 32 keys + const childHeaders = childState.hdrs(); + const roundTripState = createW3cTraceState(childHeaders.join(",")); + + Assert.equal(roundTripState.keys.length, 32, "Round trip state should have max 32 keys"); + + // Most recent keys should be preserved (child keys and most recent parent keys) + Assert.equal(roundTripState.get("child-key9"), "child-value9", "Most recent child key should be preserved"); + Assert.equal(roundTripState.get("child-key0"), "child-value0", "First child key should be preserved"); + + // Some parent keys should be dropped in the round trip + Assert.equal(roundTripState.get("key0"), undefined, "Oldest parent keys should be dropped in round trip"); + Assert.equal(roundTripState.get("key10"), undefined, "Older parent keys should be dropped in round trip"); + + // Make sure both original states are unaffected + Assert.equal(parentState.keys.length, 40, "Original parent should still have 40 keys"); + Assert.equal(childState.keys.length, 50, "Original child should still have 50 keys"); + } + }); + + this.testCase({ + name: "W3cTraceState: chain of multiple child() calls", + test: () => { + // Create a chain of trace states using child() + const state1 = createW3cTraceState("a=1"); + const state2 = state1.child(); + state2.set("b", "2"); + const state3 = state2.child(); + state3.set("c", "3"); + const state4 = state3.child(); + state4.set("d", "4"); + const state5 = state4.child(); + state5.set("e", "5"); + + // Each state should have its own keys plus all ancestor keys + Assert.equal(asString(state1), "a=1", "State 1 should have its own key"); + Assert.equal(asString(state2), "b=2,a=1", "State 2 should have its key and parent's"); + Assert.equal(asString(state3), "c=3,b=2,a=1", "State 3 should have its key and ancestors'"); + Assert.equal(asString(state4), "d=4,c=3,b=2,a=1", "State 4 should have its key and ancestors'"); + Assert.equal(asString(state5), "e=5,d=4,c=3,b=2,a=1", "State 5 should have its key and ancestors'"); + + // Modify an ancestor, changes should propagate down the chain + state2.set("b", "new-2"); + + Assert.equal(state3.get("b"), "new-2", "State 3 should see updated ancestor value"); + Assert.equal(state4.get("b"), "new-2", "State 4 should see updated ancestor value"); + Assert.equal(state5.get("b"), "new-2", "State 5 should see updated ancestor value"); + + // Delete from an ancestor, deletion should propagate + state1.del("a"); + + Assert.equal(state2.get("a"), undefined, "State 2 should see parent deletion"); + Assert.equal(state3.get("a"), undefined, "State 3 should see ancestor deletion"); + Assert.equal(state4.get("a"), undefined, "State 4 should see ancestor deletion"); + Assert.equal(state5.get("a"), undefined, "State 5 should see ancestor deletion"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() and toString consistency", + test: () => { + const parentState = createW3cTraceState("a=1,b=2"); + const childState = parentState.child(); + childState.set("c", "3"); + + // toString and asString should be equivalent + Assert.equal(asString(childState), childState.toString(), "toString and asString should be consistent"); + Assert.equal(childState.toString(), "c=3,a=1,b=2", "toString should reflect the current state"); + + // toString should update when state changes + childState.set("d", "4"); + Assert.equal(childState.toString(), "d=4,c=3,a=1,b=2", "toString should reflect updated state"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with isW3cTraceState check", + test: () => { + const parentState = createW3cTraceState("a=1"); + const childState = parentState.child(); + + // The isW3cTraceState function should recognize child instances + Assert.equal(isW3cTraceState(childState), true, "Child should be recognized as W3cTraceState"); + + // Compare with non-trace state objects + Assert.equal(isW3cTraceState({}), false, "Empty object is not a trace state"); + Assert.equal(isW3cTraceState({ keys: [] }), false, "Object with only keys is not a trace state"); + Assert.equal(isW3cTraceState(null), false, "Null is not a trace state"); + Assert.equal(isW3cTraceState(undefined), false, "Undefined is not a trace state"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() after deleting parent", + test: () => { + // Create a parent and child + const parentState = createW3cTraceState("a=1,b=2"); + const childState = parentState.child(); + + // Verify initial state + Assert.equal(childState.get("a"), "1", "Child should have parent's key"); + + // Simulate "deleting" the parent by removing all references + // (Note: we can't truly delete objects in JavaScript, but we can simulate by removing all references) + // Set all parent keys to null to simulate the parent being "gone" + parentState.del("a"); + parentState.del("b"); + + // Child should still function properly + Assert.equal(childState.get("a"), undefined, "Child should see parent's deletion"); + Assert.equal(childState.get("b"), undefined, "Child should see parent's deletion"); + + // Child should still be able to set its own values + childState.set("c", "3"); + Assert.equal(childState.get("c"), "3", "Child should be able to set new values"); + Assert.equal(asString(childState), "c=3", "Child should have correct string representation"); + } + }); + + this.testCase({ + name: "W3cTraceState: child() with special characters in values", + test: () => { + // Create parent with special characters in values + const parentState = createW3cTraceState("a=value with spaces,b=value-with-dashes,c=value_with_underscores"); + const childState = parentState.child(); + + // Child should inherit these values correctly + Assert.equal(childState.get("a"), "value with spaces", "Child should inherit values with spaces"); + Assert.equal(childState.get("b"), "value-with-dashes", "Child should inherit values with dashes"); + Assert.equal(childState.get("c"), "value_with_underscores", "Child should inherit values with underscores"); + + // Child should be able to set new values with special characters + childState.set("d", "value!with@special#chars"); + Assert.equal(childState.get("d"), "value!with@special#chars", "Child should set value with special chars"); + + // Check string representation + Assert.equal(asString(childState), + "d=value!with@special#chars,a=value with spaces,b=value-with-dashes,c=value_with_underscores", + "Child should have correct string representation with special chars"); + } + }); + + this.testCase({ + name: "W3cTraceState: serialization limits with parent and child", + test: () => { + // Create a parent with many keys + const parentState = createW3cTraceState(); + for (let i = 0; i < 20; i++) { + parentState.set("p" + i, "parent" + i); + } + + // Create a child and add many keys + const childState = parentState.child(); + for (let i = 0; i < 20; i++) { + childState.set("c" + i, "child" + i); + } + + // In memory, both should have their respective keys + Assert.equal(parentState.keys.length, 20, "Parent should have 20 keys in memory"); + Assert.equal(childState.keys.length, 40, "Child should have 20 own keys + 20 parent keys"); + + // Get the string representation of the child + const childString = asString(childState); + + // Create a new state from the string representation + const roundTripState = createW3cTraceState(childString); + + // Only 32 keys should survive the round trip + Assert.equal(roundTripState.keys.length, 32, "Round-trip state should have 32 keys max"); + + // The most recent keys should be preserved + // Child keys should all be preserved (since they're more recent) + Assert.equal(roundTripState.get("c19"), "child19", "Most recent child keys should be preserved"); + Assert.equal(roundTripState.get("c0"), "child0", "All child keys should be preserved"); + + // Only the most recent parent keys should survive + Assert.equal(roundTripState.get("p19"), "parent19", "Most recent parent keys should be preserved"); + Assert.equal(roundTripState.get("p8"), "parent8", "Recent parent keys should be preserved"); + Assert.equal(roundTripState.get("p7"), undefined, "Older parent keys should be dropped"); + Assert.equal(roundTripState.get("p0"), undefined, "Oldest parent keys should be dropped"); + + // Test hdrs with explicit maxKeys parameter + const limitedHeaders = childState.hdrs(undefined, 15); + const limitedState = createW3cTraceState(limitedHeaders.join(",")); + + Assert.equal(limitedState.keys.length, 15, "State from limited headers should respect maxKeys"); + Assert.equal(limitedState.get("c19"), "child19", "Most recent keys should be preserved with limit"); + Assert.equal(limitedState.get("c5"), "child5", "Recent keys should be preserved with limit"); + Assert.equal(limitedState.get("c4"), undefined, "Older keys should be dropped with limit"); + Assert.equal(limitedState.get("p19"), undefined, "Parent keys should be dropped with stricter limit"); + } + }); + + // Tests for snapshotW3cTraceState helper function + this.testCase({ + name: "snapshotW3cTraceState: handle null input", + test: () => { + const snapshot = snapshotW3cTraceState(null as any); + + Assert.ok(snapshot, "Should return a valid trace state instance"); + Assert.equal(snapshot.keys.length, 0, "Snapshot should have no keys"); + Assert.equal(asString(snapshot), "", "Snapshot should have empty string representation"); + Assert.equal(snapshot.isEmpty, true, "Snapshot should be empty"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: handle undefined input", + test: () => { + const snapshot = snapshotW3cTraceState(undefined as any); + + Assert.ok(snapshot, "Should return a valid trace state instance"); + Assert.equal(snapshot.keys.length, 0, "Snapshot should have no keys"); + Assert.equal(asString(snapshot), "", "Snapshot should have empty string representation"); + Assert.equal(snapshot.isEmpty, true, "Snapshot should be empty"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: handle empty trace state", + test: () => { + const original = createW3cTraceState(); + const snapshot = snapshotW3cTraceState(original); + + Assert.ok(snapshot, "Should return a valid trace state instance"); + Assert.notEqual(snapshot, original, "Should return a different instance"); + Assert.equal(snapshot.keys.length, 0, "Snapshot should have no keys"); + Assert.equal(asString(snapshot), "", "Snapshot should have empty string representation"); + Assert.equal(snapshot.isEmpty, true, "Snapshot should be empty"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: capture simple trace state", + test: () => { + const original = createW3cTraceState("a=1,b=2,c=3"); + const snapshot = snapshotW3cTraceState(original); + + Assert.ok(snapshot, "Should return a valid trace state instance"); + Assert.notEqual(snapshot, original, "Should return a different instance"); + Assert.equal(snapshot.keys.length, 3, "Snapshot should have 3 keys"); + Assert.deepEqual(snapshot.keys, ["a", "b", "c"], "Snapshot should have correct keys"); + Assert.equal(snapshot.get("a"), "1", "Snapshot should have correct value for 'a'"); + Assert.equal(snapshot.get("b"), "2", "Snapshot should have correct value for 'b'"); + Assert.equal(snapshot.get("c"), "3", "Snapshot should have correct value for 'c'"); + Assert.equal(asString(snapshot), "a=1,b=2,c=3", "Snapshot should have correct string representation"); + Assert.equal(snapshot.isEmpty, false, "Snapshot should not be empty"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: snapshot is independent from original", + test: () => { + const original = createW3cTraceState("a=1,b=2,c=3"); + const snapshot = snapshotW3cTraceState(original); + + // Initial state should be identical + Assert.equal(asString(snapshot), asString(original), "Initial state should be identical"); + + // Modify original after snapshot + original.set("d", "4"); + original.set("a", "modified"); + original.del("b"); + + // Snapshot should remain unchanged + Assert.equal(snapshot.keys.length, 3, "Snapshot should still have 3 keys"); + Assert.equal(snapshot.get("a"), "1", "Snapshot should have original value for 'a'"); + Assert.equal(snapshot.get("b"), "2", "Snapshot should still have 'b' key"); + Assert.equal(snapshot.get("c"), "3", "Snapshot should have original value for 'c'"); + Assert.equal(snapshot.get("d"), undefined, "Snapshot should not have new 'd' key"); + Assert.equal(asString(snapshot), "a=1,b=2,c=3", "Snapshot should have original string representation"); + + // Original should have changes + Assert.equal(original.keys.length, 3, "Original should have 3 keys after modification"); + Assert.equal(original.get("a"), "modified", "Original should have modified value for 'a'"); + Assert.equal(original.get("b"), undefined, "Original should not have 'b' key after deletion"); + Assert.equal(original.get("c"), "3", "Original should have original value for 'c'"); + Assert.equal(original.get("d"), "4", "Original should have new 'd' key"); + Assert.equal(asString(original), "a=modified,d=4,c=3", "Original should have modified string representation"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: snapshot with parent trace state", + test: () => { + const parentState = createW3cTraceState("parent1=value1,parent2=value2"); + const childState = createW3cTraceState("child1=cvalue1,child2=cvalue2", parentState); + + // Verify child state includes parent values + Assert.equal(childState.keys.length, 4, "Child should have 4 keys"); + Assert.equal(childState.get("parent1"), "value1", "Child should have parent1 value"); + Assert.equal(childState.get("parent2"), "value2", "Child should have parent2 value"); + Assert.equal(childState.get("child1"), "cvalue1", "Child should have child1 value"); + Assert.equal(childState.get("child2"), "cvalue2", "Child should have child2 value"); + + // Take snapshot of child state + const snapshot = snapshotW3cTraceState(childState); + + Assert.ok(snapshot, "Should return a valid trace state instance"); + Assert.notEqual(snapshot, childState, "Should return a different instance"); + Assert.equal(snapshot.keys.length, 4, "Snapshot should have 4 keys"); + Assert.equal(snapshot.get("parent1"), "value1", "Snapshot should have parent1 value"); + Assert.equal(snapshot.get("parent2"), "value2", "Snapshot should have parent2 value"); + Assert.equal(snapshot.get("child1"), "cvalue1", "Snapshot should have child1 value"); + Assert.equal(snapshot.get("child2"), "cvalue2", "Snapshot should have child2 value"); + + // Verify snapshot string representation includes all values + const snapshotStr = asString(snapshot); + Assert.ok(snapshotStr.includes("parent1=value1"), "Snapshot string should include parent1"); + Assert.ok(snapshotStr.includes("parent2=value2"), "Snapshot string should include parent2"); + Assert.ok(snapshotStr.includes("child1=cvalue1"), "Snapshot string should include child1"); + Assert.ok(snapshotStr.includes("child2=cvalue2"), "Snapshot string should include child2"); + + // Check that snapshot has no parent (is independent) + Assert.equal((snapshot as any)._p, null, "Snapshot should have no parent"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: snapshot remains unchanged when parent/child modified", + test: () => { + const parentState = createW3cTraceState("parent1=value1,parent2=value2"); + const childState = createW3cTraceState("child1=cvalue1", parentState); + + // Take snapshot before modifications + const snapshot = snapshotW3cTraceState(childState); + + // Initial verification + Assert.equal(snapshot.keys.length, 3, "Snapshot should have 3 keys initially"); + Assert.equal(snapshot.get("parent1"), "value1", "Snapshot should have parent1 value"); + Assert.equal(snapshot.get("parent2"), "value2", "Snapshot should have parent2 value"); + Assert.equal(snapshot.get("child1"), "cvalue1", "Snapshot should have child1 value"); + + // Modify parent state + parentState.set("parent1", "modified_parent1"); + parentState.set("parent3", "new_parent3"); + parentState.del("parent2"); + + // Modify child state + childState.set("child1", "modified_child1"); + childState.set("child2", "new_child2"); + + // Snapshot should remain unchanged + Assert.equal(snapshot.keys.length, 3, "Snapshot should still have 3 keys"); + Assert.equal(snapshot.get("parent1"), "value1", "Snapshot should have original parent1 value"); + Assert.equal(snapshot.get("parent2"), "value2", "Snapshot should have original parent2 value"); + Assert.equal(snapshot.get("child1"), "cvalue1", "Snapshot should have original child1 value"); + Assert.equal(snapshot.get("parent3"), undefined, "Snapshot should not have new parent3"); + Assert.equal(snapshot.get("child2"), undefined, "Snapshot should not have new child2"); + + // Child state should reflect changes + Assert.equal(childState.get("parent1"), "modified_parent1", "Child should have modified parent1"); + Assert.equal(childState.get("parent2"), undefined, "Child should not have deleted parent2"); + Assert.equal(childState.get("parent3"), "new_parent3", "Child should have new parent3"); + Assert.equal(childState.get("child1"), "modified_child1", "Child should have modified child1"); + Assert.equal(childState.get("child2"), "new_child2", "Child should have new child2"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: snapshot with overridden parent values", + test: () => { + const parentState = createW3cTraceState("a=parent_a,b=parent_b,c=parent_c"); + const childState = createW3cTraceState("a=child_a,d=child_d", parentState); + + // Verify child state has overridden parent value + Assert.equal(childState.get("a"), "child_a", "Child should have overridden value for 'a'"); + Assert.equal(childState.get("b"), "parent_b", "Child should have parent value for 'b'"); + Assert.equal(childState.get("c"), "parent_c", "Child should have parent value for 'c'"); + Assert.equal(childState.get("d"), "child_d", "Child should have its own value for 'd'"); + + // Take snapshot + const snapshot = snapshotW3cTraceState(childState); + + Assert.equal(snapshot.keys.length, 4, "Snapshot should have 4 keys"); + Assert.equal(snapshot.get("a"), "child_a", "Snapshot should have overridden value for 'a'"); + Assert.equal(snapshot.get("b"), "parent_b", "Snapshot should have parent value for 'b'"); + Assert.equal(snapshot.get("c"), "parent_c", "Snapshot should have parent value for 'c'"); + Assert.equal(snapshot.get("d"), "child_d", "Snapshot should have child value for 'd'"); + + // Verify snapshot string representation + const snapshotStr = asString(snapshot); + Assert.ok(snapshotStr.includes("a=child_a"), "Snapshot should include overridden value"); + Assert.ok(snapshotStr.includes("b=parent_b"), "Snapshot should include parent value"); + Assert.ok(snapshotStr.includes("c=parent_c"), "Snapshot should include parent value"); + Assert.ok(snapshotStr.includes("d=child_d"), "Snapshot should include child value"); + + // Modify parent's overridden key + parentState.set("a", "new_parent_a"); + + // Snapshot should still have the overridden value + Assert.equal(snapshot.get("a"), "child_a", "Snapshot should keep overridden value despite parent change"); + } + }); + + this.testCase({ + name: "snapshotW3cTraceState: snapshot with deleted parent keys", + test: () => { + const parentState = createW3cTraceState("a=parent_a,b=parent_b,c=parent_c"); + const childState = createW3cTraceState("d=child_d", parentState); + + // Delete a parent key in the child + childState.del("b"); + + // Verify child state behavior + Assert.equal(childState.get("a"), "parent_a", "Child should have parent value for 'a'"); + Assert.equal(childState.get("b"), undefined, "Child should not have deleted 'b' key"); + Assert.equal(childState.get("c"), "parent_c", "Child should have parent value for 'c'"); + Assert.equal(childState.get("d"), "child_d", "Child should have its own value for 'd'"); + + // Take snapshot + const snapshot = snapshotW3cTraceState(childState); + + Assert.equal(snapshot.keys.length, 3, "Snapshot should have 3 keys (deleted key excluded)"); + Assert.equal(snapshot.get("a"), "parent_a", "Snapshot should have parent value for 'a'"); + Assert.equal(snapshot.get("b"), undefined, "Snapshot should not have deleted 'b' key"); + Assert.equal(snapshot.get("c"), "parent_c", "Snapshot should have parent value for 'c'"); + Assert.equal(snapshot.get("d"), "child_d", "Snapshot should have child value for 'd'"); + + // Verify snapshot string representation doesn't include deleted key + const snapshotStr = asString(snapshot); + Assert.ok(!snapshotStr.includes("b="), "Snapshot should not include deleted key"); + Assert.ok(snapshotStr.includes("a=parent_a"), "Snapshot should include parent value"); + Assert.ok(snapshotStr.includes("c=parent_c"), "Snapshot should include parent value"); + Assert.ok(snapshotStr.includes("d=child_d"), "Snapshot should include child value"); + + // Re-add the deleted key in parent after snapshot + parentState.set("b", "restored_parent_b"); + + // Snapshot should still not have the key + Assert.equal(snapshot.get("b"), undefined, "Snapshot should not have restored key"); + + // Child state should also not have the key since it was deleted before child creation + Assert.equal(childState.get("b"), undefined, "Child should not have restored key"); + } + }); + } +} diff --git a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts index 613a53421..a62647c5b 100644 --- a/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts +++ b/shared/AppInsightsCore/Tests/Unit/src/aiunittests.ts @@ -13,6 +13,8 @@ import { W3cTraceParentTests } from "./W3cTraceParentTests"; import { DynamicConfigTests } from "./DynamicConfig.Tests"; import { SendPostManagerTests } from './SendPostManager.Tests'; // import { StatsBeatTests } from './StatsBeat.Tests'; +import { OTelTraceApiTests } from './OpenTelemetry/traceState.Tests'; +import { W3cTraceStateTests } from './W3TraceState.Tests'; export function runTests() { new GlobalTestHooks().registerTests(); @@ -28,6 +30,8 @@ export function runTests() { new UpdateConfigTests().registerTests(); new EventsDiscardedReasonTests().registerTests(); new W3cTraceParentTests().registerTests(); + new OTelTraceApiTests().registerTests(); + new W3cTraceStateTests().registerTests(); // new StatsBeatTests(false).registerTests(); // new StatsBeatTests(true).registerTests(); new SendPostManagerTests().registerTests(); diff --git a/shared/AppInsightsCore/src/Config/ConfigDefaultHelpers.ts b/shared/AppInsightsCore/src/Config/ConfigDefaultHelpers.ts index 8c7eedeca..eec2688e8 100644 --- a/shared/AppInsightsCore/src/Config/ConfigDefaultHelpers.ts +++ b/shared/AppInsightsCore/src/Config/ConfigDefaultHelpers.ts @@ -31,7 +31,7 @@ function _stringToBoolOrDefault(theValue: any, defaultValue: boolean, theConf * @param defaultValue - The default value to apply it not provided or it's not valid * @returns a new IConfigDefaultCheck structure */ -export function cfgDfMerge(defaultValue: V | IConfigDefaults): IConfigDefaultCheck { +export function cfgDfMerge(defaultValue: (V | undefined) | IConfigDefaults): IConfigDefaultCheck { return { mrg: true, v: defaultValue diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Enums/TraceHeadersMode.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/TraceHeadersMode.ts new file mode 100644 index 000000000..047399022 --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/TraceHeadersMode.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Controls how the SDK should look for trace headers (traceparent/tracestate) from the initial page load + * The values are bitwise OR'd together to allow for multiple values to be set at once. + * @since 3.4.0 + */ +export const enum eTraceHeadersMode { + /** + * Don't look for any trace headers + */ + None = 0x00, + + /** + * Look for traceparent header/meta tag + */ + TraceParent = 0x01, + + /** + * Look for tracestate header/meta tag + */ + TraceState = 0x02, + + /** + * Look for both traceparent and tracestate headers/meta tags + */ + All = 0x03 +} diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Enums/W3CTraceFlags.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/W3CTraceFlags.ts new file mode 100644 index 000000000..b9d329b6e --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Enums/W3CTraceFlags.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * The TelemetryUpdateReason enumeration contains a set of bit-wise values that specify the reason for update request. + */ +export const enum eW3CTraceFlags { + /** + * No sampling decision has been made. + */ + None = 0, + + /** + * Represents that the trace has been sampled. + * @remarks This value is used to indicate that the trace has been sampled. + */ + Sampled = 1 +} diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts index 39859e849..1ed4a92f3 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IAppInsightsCore.ts @@ -221,7 +221,7 @@ export interface IAppInsightsCore void, sendReason?: SendRequestReason, cbTimeout?: number): boolean | void; /** - * Gets the current distributed trace context for this instance if available + * 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; diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts index bca694a56..66b473010 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IConfiguration.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import { IPromise } from "@nevware21/ts-async"; +import { eTraceHeadersMode } from "../JavaScriptSDK.Enums/TraceHeadersMode"; import { IAppInsightsCore } from "./IAppInsightsCore"; import { IChannelControls } from "./IChannelControls"; import { ICookieMgrConfig } from "./ICookieMgr"; @@ -10,9 +11,6 @@ import { INotificationManager } from "./INotificationManager"; import { IPerfManager } from "./IPerfManager"; import { ITelemetryPlugin } from "./ITelemetryPlugin"; -//import { IStatsBeatConfig } from "./IStatsBeat"; -"use strict"; - /** * Configuration provided to SDK core */ @@ -252,6 +250,13 @@ export interface IConfiguration { // * @internal // */ //_sdk?: IInternalSdkConfiguration; + + /** + * [Optional] Controls if the SDK should look for the `traceparent` and/or `tracestate` values from + * the service timing headers or meta tags from the initial page load. + * @defaultValue eTraceHeadersMode.All + */ + traceHdrMode?: eTraceHeadersMode; } ///** diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts index 1655ce517..3778a766e 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IDistributedTraceContext.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { IW3cTraceState } from "./IW3cTraceState"; + export interface IDistributedTraceContext { /** @@ -9,8 +11,13 @@ export interface IDistributedTraceContext { getName(): string; /** - * Sets the current name of the page + * Sets the current name of the page, also updates the name for any parent context. + * This is used to identify the page in the telemetry data. + * @remarks This function updates the current and ALL parent contexts with the new name, + * to just update the name of the current context, use the `pageName` property. * @param pageName - The name of the page + * @deprecated Use the `pageName` property to avoid the side effect of changing the page name of all + * parent contexts. */ setName(pageName: string): void; @@ -25,6 +32,12 @@ export interface IDistributedTraceContext { * Set the unique identifier for a trace. All requests / spans from the same trace share the same traceId. * Must be conform to the W3C TraceContext specification, in a hex representation of 16-byte array. * A.k.a. trace-id, TraceID or Distributed TraceID https://www.w3.org/TR/trace-context/#trace-id + * + * @remarks Sets the traceId for the current context AND all parent contexts, if you want to set the traceId + * for the current context only, use the `traceId` property. + * @param newValue - The traceId to set + * @deprecated Use the `traceId` property to avoid the side effect of changing the traceId of all + * parent contexts. */ setTraceId(newValue: string): void; @@ -38,6 +51,12 @@ export interface IDistributedTraceContext { * Self-generated 8-bytes identifier of the incoming request. Must be a hex representation of 8-byte array. * Also know as the parentId, used to link requests together * https://www.w3.org/TR/trace-context/#parent-id + * + * @remarks Sets the spanId for the current context AND all parent contexts, if you want to set the spanId for + * the current context only, use the `spanId` property. + * @param newValue - The spanId to set + * @deprecated Use the `spanId` property to avoid the side effect of changing the spanId of all + * parent contexts. */ setSpanId(newValue: string): void; @@ -48,7 +67,85 @@ export interface IDistributedTraceContext { /** * https://www.w3.org/TR/trace-context/#trace-flags + * @remarks Sets the trace flags for the current context and ALL parent contexts, if you want to set the trace + * flags for the current context only, use the `traceFlags` property. * @param newValue - An integer representation of the W3C TraceContext trace-flags. + * @deprecated Use the `traceFlags` property to avoid the side effect of changing the traceFlags of all + * parent contexts. */ setTraceFlags(newValue?: number): void; + + /** + * Returns the current name of the page + * @remarks This function updates the current context only, to update the name of the current and ALL parent contexts, + * use the `setName` method. + * @default undefined + * @since 3.4.0 + */ + pageName: string; + + /** + * The current 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. + * @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. + * @since 3.4.0 + */ + 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. + * If you update this value, it will only update for the current context, not the parent context. + * @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 `setSpanId` method. + * @since 3.4.0 + */ + spanId: string; + + /** + * Returns true if the current context was initialized (propagated) from a remote parent. + * @since 3.4.0 + * @default false + * @returns True if the context was propagated from a remote parent + */ + readonly 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. + * + * @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 `setTraceFlags` method. + * @since 3.4.0 + */ + traceFlags?: number; + + /** + * 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 + * modify it directly you do not need to reassign the new value to this property. + * @since 3.4.0 + */ + readonly traceState: IW3cTraceState; + + /** + * Provides access to the parent context of the current context. + * @remarks This is a read-only property, you cannot modify the parent context directly, you can only + * modify the current context. If you need to modify the parent context, you need to do it through the + * current context using the `setTraceId`, `setSpanId`, `setTraceFlags` and `setName` methods. + * @default null + * @since 3.4.0 + */ + readonly parentCtx?: IDistributedTraceContext | null; } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IProcessTelemetryContext.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IProcessTelemetryContext.ts index 891afde5e..3873be386 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IProcessTelemetryContext.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IProcessTelemetryContext.ts @@ -35,9 +35,12 @@ export interface IBaseProcessingContext { getCfg: () => IConfiguration; /** - * Gets the named extension config + * Gets the named extension configuration + * @param identifier - The named extension identifier + * @param defaultValue - The default value(s) to return if no defined config exists + * @param rootOnly - If true, only the look for the configuration in the top level and not in the "extensionConfig" */ - getExtCfg: (identifier: string, defaultValue?: IConfigDefaults) => T; + getExtCfg: (identifier: string, defaultValue?: IConfigDefaults, rootOnly?: boolean) => T; /** * Gets the named config from either the named identifier extension or core config if neither exist then the diff --git a/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IW3cTraceState.ts b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IW3cTraceState.ts new file mode 100644 index 000000000..3ff00582d --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK.Interfaces/IW3cTraceState.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Represents a mutable [W3C trace state list](https://www.w3.org/TR/trace-context/#tracestate-header), this is a + * list of key/value pairs that are used to pass trace state information between different tracing systems. The + * list is ordered and the order is important as it determines the processing order. + * + * Importantly instances of this type are mutable, change made to an instance via {@link IW3cTraceState.set} or + * {@link IW3cTraceState.del} will be reflected on the instance and any child instances that use it as a parent. + * However, any parent instance associated with an instance will not be modified by operations on that particular + * instance. + * + * @since 3.4.0 + */ +export interface IW3cTraceState { + /** + * Returns a readonly array of the current keys associated with the trace state, keys are returned in the + * required processing order and if this instance has a parent the keys from the parent will be included + * unless they have been removed (deleted) from the child instance. + * Once created any modifications to the parent will also be reflected in the child, this is different from + * the OpenTelemetry implementation which creates a new instance for each call. + * @returns A readonly array of the current keys associated with the trace state + */ + readonly keys: string[]; + + /** + * Check if the trace state list is empty, meaning it has no keys or values. + * This exists to allow for quick checks without needing to create a new array of keys. + * @since 3.4.0 + * @returns true if the trace state list is empty, false otherwise + */ + readonly isEmpty: boolean; + + /** + * Get the value for the specified key that is associated with this instance, either directly or from the parent. + * @param key - The key to lookup + * @returns The value for the key, or undefined if not found + */ + get(key: string): string | undefined; + + /** + * Set the value for the specified key for this instance, returning its new location within the list. + * - 0 is the front of the list + * - -1 not set because the key/value pair is invalid + * If the key already exists it will be removed from its current location and added to the front of the list. And + * if the key was in the parent this will override the value inherited from the parent, more importantly it will + * not modify the parent value. + * @param key - The key to set + * @param value - The value to set + * @returns 0 if successful, -1 if not + */ + set(key: string, value: string): number; + + /** + * Delete the specified key from this instance, if the key was in the parent it will be removed (hidden) from + * this instance but will still be available directly from the parent. + * @param key - The key to delete + */ + del(key: string): void; + + /** + * Format the trace state list into a strings where each string can be used as a header value. + * This will return an empty array if the trace state list is empty. + * @param maxHeaders - The maximum number of entries to include in the output, once the limit is reached no more entries will be included + * @param maxKeys - The maximum number of keys to include in the output, once the limit is reached no more keys will be included + * @param maxLen - The maximum length of each header value, once the limit is reached a new header value will be created + * @returns An array of strings that can be used for the header values, if the trace state list is empty an empty array will be returned + */ + hdrs(maxHeaders?: number, maxKeys?: number, maxLen?: number): string[]; + + /** + * Create a new instance of IW3cTraceState which is a child of this instance, meaning it will inherit the keys + * and values from this instance but any changes made to the child will not affect this instance. + * @returns A new instance of IW3cTraceState which is a child of this instance + */ + child(): IW3cTraceState; +} diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts b/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts index 25e6cb358..ebef72a3b 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/AppInsightsCore.ts @@ -18,6 +18,7 @@ import { _eInternalMessageId, eLoggingSeverity } from "../JavaScriptSDK.Enums/Lo import { SendRequestReason } from "../JavaScriptSDK.Enums/SendRequestReason"; import { TelemetryUnloadReason } from "../JavaScriptSDK.Enums/TelemetryUnloadReason"; import { TelemetryUpdateReason } from "../JavaScriptSDK.Enums/TelemetryUpdateReason"; +import { eTraceHeadersMode } from "../JavaScriptSDK.Enums/TraceHeadersMode"; import { IAppInsightsCore, ILoadedPlugin } from "../JavaScriptSDK.Interfaces/IAppInsightsCore"; import { IChannelControls } from "../JavaScriptSDK.Interfaces/IChannelControls"; import { IChannelControlsHost } from "../JavaScriptSDK.Interfaces/IChannelControlsHost"; @@ -36,6 +37,9 @@ import { ITelemetryPluginChain } from "../JavaScriptSDK.Interfaces/ITelemetryPlu import { ITelemetryUnloadState } from "../JavaScriptSDK.Interfaces/ITelemetryUnloadState"; import { ITelemetryUpdateState } from "../JavaScriptSDK.Interfaces/ITelemetryUpdateState"; 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 { doUnloadAll, runTargetUnload } from "./AsyncUtils"; import { ChannelControllerPriority } from "./Constants"; import { createCookieMgr } from "./CookieMgr"; @@ -55,6 +59,8 @@ import { _getPluginState, createDistributedTraceContext, initializePlugins, sort import { TelemetryInitializerPlugin } from "./TelemetryInitializerPlugin"; import { IUnloadHandlerContainer, UnloadHandler, createUnloadHandlerContainer } from "./UnloadHandlerContainer"; import { IUnloadHookContainer, createUnloadHookContainer } from "./UnloadHookContainer"; +import { findW3cTraceParent } from "./W3cTraceParent"; +import { findW3cTraceState } from "./W3cTraceState"; // import { IStatsBeat, IStatsBeatConfig, IStatsBeatState } from "../JavaScriptSDK.Interfaces/IStatsBeat"; // import { IStatsMgr } from "../JavaScriptSDK.Interfaces/IStatsMgr"; @@ -95,7 +101,8 @@ const defaultConfig: IConfigDefaults = objDeepFreeze({ [STR_EXTENSION_CONFIG]: { ref: true, v: {} }, [STR_CREATE_PERF_MGR]: UNDEFINED_VALUE, loggingLevelConsole: eLoggingSeverity.DISABLED, - diagnosticLogInterval: UNDEFINED_VALUE + diagnosticLogInterval: UNDEFINED_VALUE, + traceHdrMode: eTraceHeadersMode.All // _sdk: { rdOnly: true, ref: true, v: defaultSdkConfig } }); @@ -115,7 +122,7 @@ function _validateExtensions(logger: IDiagnosticLogger, channelPriority: number, // Check if any two extensions have the same priority, then warn to console // And extract the local extensions from the - let extPriorities = {}; + let extPriorities: any = {}; // Extension validation arrForEach(allExtensions, (ext: ITelemetryPlugin) => { @@ -260,6 +267,24 @@ function _createUnloadHook(unloadHook: IUnloadHook): IUnloadHook { }, "toJSON", { v: () => "aicore::onCfgChange<" + JSON.stringify(unloadHook) + ">" }); } +function _getParentTraceCtx(mode: eTraceHeadersMode): IOTelSpanContext | null { + let spanContext: IOTelSpanContext | null = null; + const parentTrace = (mode & eTraceHeadersMode.TraceParent) ? findW3cTraceParent() : null; + const parentTraceState = (mode & eTraceHeadersMode.TraceState) ? findW3cTraceState() : null; + + if (parentTrace || parentTraceState) { + spanContext = createOTelSpanContext({ + 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 + }); + } + + return spanContext; +} + /** * @group Classes * @group Entrypoint @@ -306,6 +331,8 @@ export class AppInsightsCore im let _channels: IChannelControls[] | null; let _isUnloading: boolean; let _telemetryInitializerPlugin: TelemetryInitializerPlugin; + let _serverOTelCtx: IOTelSpanContext | null; + let _serverTraceHdrMode: eTraceHeadersMode; let _internalLogsEventName: string | null; let _evtNamespace: string; let _unloadHandlers: IUnloadHandlerContainer; @@ -336,7 +363,7 @@ export class AppInsightsCore im _initDefaults(); // Special internal method to allow the unit tests and DebugPlugin to hook embedded objects - _self["_getDbgPlgTargets"] = () => { + (_self as any)["_getDbgPlgTargets"] = () => { return [_extensions, _eventQueue]; }; @@ -382,6 +409,12 @@ export class AppInsightsCore im objForEachKey(extCfg, (key) => { details.ref(extCfg, key); }); + + if (rootCfg.traceHdrMode !== _serverTraceHdrMode) { + // Create a new trace context if it doesn't exist and the mode is not None + _serverOTelCtx = _getParentTraceCtx(rootCfg.traceHdrMode); + _serverTraceHdrMode = rootCfg.traceHdrMode; + } })); _notificationManager = notificationManager; @@ -476,7 +509,7 @@ export class AppInsightsCore im if (!_notificationManager) { _notificationManager = new NotificationManager(_configHandler.cfg); // For backward compatibility only - _self[strNotificationManager] = _notificationManager; + (_self as any)[strNotificationManager] = _notificationManager; } return _notificationManager; @@ -959,8 +992,9 @@ export class AppInsightsCore im _self.flush = _flushChannels; _self.getTraceCtx = (createNew?: boolean): IDistributedTraceContext | null => { - if (!_traceCtx) { - _traceCtx = createDistributedTraceContext(); + + if ((!_traceCtx && createNew !== false) || createNew === true) { + _traceCtx = createDistributedTraceContext(_serverOTelCtx); } return _traceCtx; @@ -1084,6 +1118,8 @@ export class AppInsightsCore im arrAppend(cfgExtensions, _extensions); _telemetryInitializerPlugin = new TelemetryInitializerPlugin(); + _serverOTelCtx = null; + _serverTraceHdrMode = eTraceHeadersMode.None; _eventQueue = []; runTargetUnload(_notificationManager, false); _notificationManager = null; @@ -1621,15 +1657,24 @@ export class AppInsightsCore im * @param callBack - if specified, notify caller when send is complete, the channel should return true to indicate to the caller that it will be called. * If the caller doesn't return true the caller should assume that it may never be called. * @param sendReason - specify the reason that you are calling "flush" defaults to ManualFlush (1) if not specified - * @returns - true if the callback will be return after the flush is complete otherwise the caller should assume that any provided callback will never be called + * @returns true if the callback will be return after the flush is complete otherwise the caller should assume that any provided callback will never be called */ public flush(isAsync?: boolean, callBack?: (flushComplete?: boolean) => void, sendReason?: SendRequestReason): void { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging } /** - * Gets the current distributed trace context for this instance if available - * @param createNew - Optional flag to create a new instance if one doesn't currently exist, defaults to true + * Gets the current distributed trace context for this instance if available, you can optional + * create a new instance if one does not currently exist or return null if one does not currently exist. + * When a server context is available it will be used as the parent context for the any new instance created + * (when createNew is true or no instance currently exists), calling this function will not + * change the current distributed trace context, it will only return the current context + * or create a new instance if one does not currently exist. + * @param createNew - Optional flag to create a new instance if one doesn't currently exist, defaults to + * undefined which will only create a new instance if one does not currently exist. + * If set to `false` then it will return null if no distributed trace context is available. + * If set to `true` then a new instance will be created even if one already exists. + * @param createNew - Optional flag to create a new instance if one doesn't currently exist, defaults to */ public getTraceCtx(createNew?: boolean): IDistributedTraceContext | null { // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/EnvUtils.ts b/shared/AppInsightsCore/src/JavaScriptSDK/EnvUtils.ts index 2b8e9dcfc..e4be9b01b 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/EnvUtils.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/EnvUtils.ts @@ -4,7 +4,8 @@ import { getGlobal, strShimObject, strShimPrototype, strShimUndefined } from "@microsoft/applicationinsights-shims"; import { - getDocument, getInst, getNavigator, getPerformance, hasNavigator, isFunction, isString, isUndefined, mathMax, strIndexOf, strSubstring + arrForEach, getDocument, getInst, getNavigator, getPerformance, hasNavigator, isFunction, isNullOrUndefined, isString, isUndefined, + mathMax, strIndexOf, strSubstring } from "@nevware21/ts-utils"; import { IConfiguration } from "../applicationinsights-core-js"; import { strContains } from "./HelperFuncs"; @@ -270,19 +271,19 @@ export function isXhrSupported(): boolean { } -function _getNamedValue(values: any, name: string) { +function _getNamedValue(values: any, name: string): T[] { + let items: T[] = []; if (values) { - for (var i = 0; i < values.length; i++) { - var value = values[i] as any; + arrForEach(values, (value) => { if (value.name) { if(value.name === name) { - return value; + items.push(value); } } - } + }); } - return {}; + return items; } /** @@ -290,13 +291,31 @@ function _getNamedValue(values: any, name: string) { * @param name - The name of the meta-tag to find. */ export function findMetaTag(name: string): any { + let tags = findMetaTags(name); + if (tags.length > 0) { + return tags[0]; + } + + return null; +} + +/** + * Helper function to fetch all named meta-tag from the page. + * @since 3.4.0 + * @param name - The name of the meta-tag to find. + * @returns - An array of meta-tag values. + */ +export function findMetaTags(name: string): string[] { + let tags: string[] = []; let doc = getDocument(); if (doc && name) { // Look for a meta-tag - return _getNamedValue(doc.querySelectorAll("meta"), name).content; + arrForEach(_getNamedValue(doc.querySelectorAll("meta"), name), (item) => { + tags.push(item.content); + }); } - return null; + return tags; } /** @@ -305,14 +324,36 @@ export function findMetaTag(name: string): any { */ export function findNamedServerTiming(name: string): any { let value: any; + let serverTimings = findNamedServerTimings(name); + if (serverTimings.length > 0) { + value = serverTimings[0]; + } + + return value; +} + +/** + * Helper function to fetch the named server timing value from the page response (first navigation event). + * @since 3.4.0 + * @param name - The name of the server timing value to find. + * @returns - An array of server timing values. + */ +export function findNamedServerTimings(name: string): string[] { + let values: string[] = []; let perf = getPerformance(); - if (perf) { + if (perf && perf.getEntriesByType) { // Try looking for a server-timing header - let navPerf = perf.getEntriesByType("navigation") || []; - value = _getNamedValue((navPerf.length > 0 ? navPerf[0] : {} as any).serverTiming, name).description; + arrForEach(perf.getEntriesByType("navigation") || [], (navPerf: any) => { + arrForEach(_getNamedValue(navPerf.serverTiming, name), (value: any) => { + let desc = value.description; + if (!isNullOrUndefined(desc)) { + values.push(desc); + } + }); + }); } - return value; + return values; } // TODO: should reuse this method for analytics plugin @@ -487,4 +528,4 @@ export function fieldRedaction(input: string, config: IConfiguration): string { } catch (e) { return input; } -} \ No newline at end of file +} diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts b/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts index d5b10999f..d0393e57b 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/ProcessTelemetryContext.ts @@ -23,7 +23,7 @@ import { ITelemetryUnloadState } from "../JavaScriptSDK.Interfaces/ITelemetryUnl import { ITelemetryUpdateState } from "../JavaScriptSDK.Interfaces/ITelemetryUpdateState"; import { _throwInternal, safeGetLogger } from "./DiagnosticLogger"; import { proxyFunctions } from "./HelperFuncs"; -import { STR_CORE, STR_DISABLED, STR_EMPTY, STR_EXTENSION_CONFIG } from "./InternalConstants"; +import { STR_CORE, STR_DISABLED, STR_EMPTY } from "./InternalConstants"; import { doPerf } from "./PerfManager"; import { _getPluginState } from "./TelemetryHelpers"; @@ -33,6 +33,13 @@ const strGetTelCtx = "_getTelCtx"; let _chainId = 0; +/** + * Identifies the type for the `extensionConfig` property + * This is a key/value pair where the key is the name of the extension and the value is the configuration + * for that extension. + */ +type ExtensionConfig = { [key: string]: any }; + interface OnCompleteCallback { func: () => void; self: any; // This for the function @@ -155,37 +162,35 @@ function _createInternalContext(telemetryChain function _getExtCfg(identifier: string, createIfMissing: boolean) { let idCfg: T = null; - let cfg = dynamicHandler.cfg; + let extCfg: ExtensionConfig = _getCfg(dynamicHandler.cfg, "extensionConfig", createIfMissing); + + if (extCfg) { + idCfg = _getCfg(extCfg, identifier, createIfMissing); + } + + return idCfg; + } + + function _getCfg(cfg: Cfg, identifier: string, createIfMissing: boolean) { + let idCfg: T = null; if (cfg && identifier) { - let extCfg = cfg.extensionConfig; - if (!extCfg && createIfMissing) { - extCfg = {}; + idCfg = cfg[identifier] as T; + if (!idCfg && createIfMissing) { + idCfg = {} as T; } // Always set the value so that the property always exists - cfg[STR_EXTENSION_CONFIG] = extCfg; // Note: it is valid for the "value" to be undefined + (cfg as any)[identifier] = idCfg; // Note: it is valid for the "value" to be undefined // Calling `ref()` has a side effect of causing the referenced property to become dynamic (if not already) - extCfg = dynamicHandler.ref(cfg, STR_EXTENSION_CONFIG); - if (extCfg) { - idCfg = extCfg[identifier]; - if (!idCfg && createIfMissing) { - idCfg = {} as T; - } - - // Always set the value so that the property always exists - extCfg[identifier] = idCfg; // Note: it is valid for the "value" to be undefined - - // Calling `ref()` has a side effect of causing the referenced property to become dynamic (if not already) - idCfg = dynamicHandler.ref(extCfg, identifier); - } + idCfg = dynamicHandler.ref(cfg, identifier); } return idCfg; } - function _resolveExtCfg(identifier: string, defaultValues: IConfigDefaults): T { - let newConfig: T = _getExtCfg(identifier, true); + function _resolveExtCfg(identifier: string, defaultValues: IConfigDefaults, rootOnly?: boolean): T { + let newConfig: T = rootOnly ? _getCfg(dynamicHandler.cfg, identifier, true) : _getExtCfg(identifier, true); if (defaultValues) { // Enumerate over the defaultValues and if not already populated attempt to diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts b/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts index 5e4c87281..bb2ec6088 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/TelemetryHelpers.ts @@ -1,18 +1,22 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { arrForEach, isFunction } from "@nevware21/ts-utils"; +import { arrForEach, isFunction, objDefineProps } from "@nevware21/ts-utils"; import { IAppInsightsCore } from "../JavaScriptSDK.Interfaces/IAppInsightsCore"; import { IDistributedTraceContext } 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 { ITraceParent } from "../JavaScriptSDK.Interfaces/ITraceParent"; 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 { STR_CORE, STR_PRIORITY, STR_PROCESS_TELEMETRY } from "./InternalConstants"; +import { getLocation } from "./EnvUtils"; +import { STR_CORE, STR_EMPTY, STR_PRIORITY, STR_PROCESS_TELEMETRY, UNDEFINED_VALUE } from "./InternalConstants"; import { isValidSpanId, isValidTraceId } from "./W3cTraceParent"; +import { createW3cTraceState } from "./W3cTraceState"; export interface IPluginState { core?: IAppInsightsCore; @@ -90,7 +94,7 @@ export function initializePlugins(processContext: IProcessTelemetryContext, exte export function sortPlugins(plugins:T[]) { // Sort by priority - return plugins.sort((extA, extB) => { + return plugins.sort((extA: any, extB: any) => { let result = 0; if (extB) { let bHasProcess = extB[STR_PROCESS_TELEMETRY]; @@ -137,47 +141,191 @@ export function unloadComponents(components: any | IUnloadableComponent[], unloa return _doUnload(); } +function isDistributedTraceContext(obj: any): obj is IDistributedTraceContext { + return obj && + isFunction(obj.getName) && + isFunction(obj.getTraceId) && + isFunction(obj.getSpanId) && + isFunction(obj.getTraceFlags) && + isFunction(obj.setName) && + isFunction(obj.setTraceId) && + isFunction(obj.setSpanId) && + isFunction(obj.setTraceFlags); +} /** - * Creates a IDistributedTraceContext which optionally also "sets" the value on a parent - * @param parentCtx - An optional parent distributed trace instance - * @returns A new IDistributedTraceContext instance that uses an internal temporary object + * 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. + * 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 + * - **spanId**: Inherited from parent if valid, otherwise empty string + * - **traceFlags**: Inherited from parent if available, otherwise undefined + * - **pageName**: Inherited from parent context or derived from current location, defaults to "_unknown_" + * - **traceState**: Lazily created W3C trace state, inheriting from parent if available + * + * @remarks + * This function ensures consistent distributed tracing by guaranteeing that every context has a valid traceId, + * 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(parentCtx?: IDistributedTraceContext): IDistributedTraceContext { - let trace: ITraceParent = {} as ITraceParent; +export function createDistributedTraceContext(parent?: IDistributedTraceContext | IOTelSpanContext): IDistributedTraceContext { + let parentCtx: IDistributedTraceContext = null; + let spanContext: IOTelSpanContext = 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; + let isRemote = parent ? parent.isRemote : false; + let pageName = STR_EMPTY; + let traceState: IW3cTraceState = null; + + if (parent) { + if (isDistributedTraceContext(parent)) { + parentCtx = parent; + pageName = parentCtx.getName(); + } else { + spanContext = parent; + } + } + + if (!pageName) { + pageName = "_unknown_"; + // If we have a location, use that as the page name + let location = getLocation(); + if (location && location.pathname) { + pageName = location.pathname + (location.hash || ""); + } + } + + function _getName(): string { + return pageName; + } + + function _setPageNameFn(updateParent: boolean) { + return function (newValue: string): void { + if (updateParent) { + parentCtx && parentCtx.setName(newValue); + } + + pageName = newValue; + }; + } + + function _getTraceId(): string { + return traceId; + } + + function _setTraceIdFn(updateParent: boolean) { + return function (newValue: string): void { + if (updateParent) { + parentCtx && parentCtx.setTraceId(newValue); + } - return { - getName: (): string => { - return (trace as any).name; - }, - setName: (newValue: string): void => { - parentCtx && parentCtx.setName(newValue); - (trace as any).name = newValue; - }, - getTraceId: (): string => { - return trace.traceId; - }, - setTraceId: (newValue: string): void => { - parentCtx && parentCtx.setTraceId(newValue); if (isValidTraceId(newValue)) { - trace.traceId = newValue + traceId = newValue } - }, - getSpanId: (): string => { - return trace.spanId; - }, - setSpanId: (newValue: string): void => { - parentCtx && parentCtx.setSpanId(newValue); + }; + } + + function _getSpanId(): string { + return spanId; + } + + function _setSpanIdFn(updateParent: boolean) { + return function (newValue: string): void { + if (updateParent) { + parentCtx && parentCtx.setSpanId(newValue); + } + if (isValidSpanId(newValue)) { - trace.spanId = newValue + spanId = newValue } + }; + } + + function _getTraceFlags(): number { + return traceFlags; + } + + function _setTraceFlagsFn(updateParent: boolean) { + return function (newTraceFlags?: number): void { + if (updateParent) { + parentCtx && parentCtx.setTraceFlags(newTraceFlags); + } + + traceFlags = newTraceFlags; + }; + } + + 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); + } + } + + return traceState; + } + + let traceCtx: IDistributedTraceContext = { + getName: _getName, + setName: _setPageNameFn(true), + getTraceId: _getTraceId, + setTraceId: _setTraceIdFn(true), + getSpanId: _getSpanId, + setSpanId: _setSpanIdFn(true), + getTraceFlags: _getTraceFlags, + setTraceFlags: _setTraceFlagsFn(true), + traceId, + spanId, + traceFlags, + traceState, + isRemote, + pageName + }; + + return objDefineProps(traceCtx, { + pageName: { + g: _getName, + s: _setPageNameFn(false) + }, + traceId: { + g: _getTraceId, + s: _setTraceIdFn(false) }, - getTraceFlags: (): number => { - return trace.traceFlags; + spanId: { + g: _getSpanId, + s: _setSpanIdFn(false) }, - setTraceFlags: (newTraceFlags?: number): void => { - parentCtx && parentCtx.setTraceFlags(newTraceFlags); - trace.traceFlags = newTraceFlags + traceFlags: { + g: _getTraceFlags, + s: _setTraceFlagsFn(false) + }, + isRemote: { + v: isRemote, + w: false + + }, + traceState: { + g: _getTraceState + }, + parentCtx: { + g: () => parentCtx } - }; + }); } diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceParent.ts b/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceParent.ts index 5faafaacc..465cfc73e 100644 --- a/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceParent.ts +++ b/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceParent.ts @@ -1,4 +1,5 @@ -import { arrForEach, isArray, isString, strLeft, strTrim } from "@nevware21/ts-utils"; +import { arrForEach, isArray, isNullOrUndefined, isString, strLeft, strTrim } from "@nevware21/ts-utils"; +import { eW3CTraceFlags } from "../JavaScriptSDK.Enums/W3CTraceFlags"; import { ITraceParent } from "../JavaScriptSDK.Interfaces/ITraceParent"; import { generateW3CId } from "./CoreUtils"; import { findMetaTag, findNamedServerTiming } from "./EnvUtils"; @@ -8,8 +9,8 @@ import { STR_EMPTY } from "./InternalConstants"; const TRACE_PARENT_REGEX = /^([\da-f]{2})-([\da-f]{32})-([\da-f]{16})-([\da-f]{2})(-[^\s]{1,64})?$/i; const DEFAULT_VERSION = "00"; const INVALID_VERSION = "ff"; -const INVALID_TRACE_ID = "00000000000000000000000000000000"; -const INVALID_SPAN_ID = "0000000000000000"; +export const INVALID_TRACE_ID = "00000000000000000000000000000000"; +export const INVALID_SPAN_ID = "0000000000000000"; const SAMPLED_FLAG = 0x01; function _isValid(value: string, len: number, invalidValue?: string): boolean { @@ -55,7 +56,7 @@ export function createTraceParent(traceId?: string, spanId?: string, flags?: num version: _isValid(version, 2, INVALID_VERSION) ? version : DEFAULT_VERSION, traceId: isValidTraceId(traceId) ? traceId : generateW3CId(), spanId: isValidSpanId(spanId) ? spanId : strLeft(generateW3CId(), 16), - traceFlags: flags >= 0 && flags <= 0xFF ? flags : 1 + traceFlags: (!isNullOrUndefined(flags) && flags >= 0 && flags <= 0xFF ? flags : eW3CTraceFlags.Sampled) }; } @@ -74,7 +75,7 @@ export function parseTraceParent(value: string, selectIdx?: number): ITraceParen if (isArray(value)) { // The value may have been encoded on the page into an array so handle this automatically - value = value[0] || ""; + value = value[0] || STR_EMPTY; } if (!value || !isString(value) || value.length > 8192) { @@ -88,6 +89,7 @@ export function parseTraceParent(value: string, selectIdx?: number): ITraceParen } // See https://www.w3.org/TR/trace-context/#versioning-of-traceparent + TRACE_PARENT_REGEX.lastIndex = 0; const match = TRACE_PARENT_REGEX.exec(strTrim(value)); if (!match || // No match match[1] === INVALID_VERSION || // version ff is forbidden @@ -185,7 +187,7 @@ export function formatTraceParent(value: ITraceParent) { return `${version.toLowerCase()}-${_formatValue(value.traceId, 32, INVALID_TRACE_ID).toLowerCase()}-${_formatValue(value.spanId, 16, INVALID_SPAN_ID).toLowerCase()}-${flags.toLowerCase()}`; } - return ""; + return STR_EMPTY; } /** diff --git a/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts b/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts new file mode 100644 index 000000000..bbacad69f --- /dev/null +++ b/shared/AppInsightsCore/src/JavaScriptSDK/W3cTraceState.ts @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + ICachedValue, WellKnownSymbols, arrForEach, arrIndexOf, createCachedValue, createDeferredCachedValue, getKnownSymbol, isArray, + isFunction, isNullOrUndefined, isString, objDefine, objDefineProps, safe, strSplit +} from "@nevware21/ts-utils"; +import { IW3cTraceState } from "../JavaScriptSDK.Interfaces/IW3cTraceState"; +import { findMetaTags, findNamedServerTimings } from "./EnvUtils"; +import { STR_EMPTY } from "./InternalConstants"; + +const MAX_TRACE_STATE_MEMBERS = 32; +const MAX_TRACE_STATE_LEN = 512; + +// https://www.w3.org/TR/trace-context-1/#key +const LCALPHA = "[a-z]"; +const LCALPHA_DIGIT = "[a-z\\d]"; +const LCALPHA_DIGIT_UNDERSCORE_DASH_STAR_SLASH = "[a-z\\d_\\-*\\/]"; +const SIMPLE_KEY = "(" + LCALPHA + LCALPHA_DIGIT_UNDERSCORE_DASH_STAR_SLASH + "{0,255})"; +const TENANT_ID = "(" + LCALPHA_DIGIT + LCALPHA_DIGIT_UNDERSCORE_DASH_STAR_SLASH + "{0,240})"; +const SYSTEM_ID = "(" + LCALPHA + LCALPHA_DIGIT_UNDERSCORE_DASH_STAR_SLASH + "{0,13})"; +const MULTI_TENANT_KEY = "(" + TENANT_ID + "@" + SYSTEM_ID + ")"; + +// https://www.w3.org/TR/trace-context-1/#value +const NBLK_CHAR = "\x21-\x2B\\--\x3C\x3E-\x7E"; +const TRACESTATE_VALUE = "[\x20" + NBLK_CHAR + "]{0,255}[" + NBLK_CHAR + "]"; + +// https://www.w3.org/TR/trace-context-1/#tracestate-header +const TRACESTATE_KVP_REGEX = new RegExp("^\\s*((?:" + SIMPLE_KEY + "|" + MULTI_TENANT_KEY + ")=(" + TRACESTATE_VALUE + "))\\s*$"); + +/** + * @internal + * @ignore + * Identifies the components of a multi-tenant key + */ +interface ITraceStateMultiTenantKey { + tenantId: string; + systemId: string; +} + +/** + * @internal + * @ignore + * Identifies the member entry type + */ +const enum eTraceStateKeyType { + simple = 0, + multiTenant = 1, + + /** + * Internal flag the identifies that the associated key has been deleted + */ + deleted = 2 +} + +/** + * @internal + * @ignore + * Represents the parsed trace state member + */ +interface ITraceStateMember { + /** + * Identifies the type of identified key, simple or multi-tenant + */ + readonly type: eTraceStateKeyType; + + /** + * The full key of the trace state member + */ + readonly key: string; + + /** + * When the {@link #type} is {@link eTraceStateKeyType.multiTenant}, the tenantId and systemId will be populated + * with the values from the key, otherwise this entry will be undefined or null and should be ignored. + */ + readonly multiTenant?: ITraceStateMultiTenantKey; + + /** + * The value associated with the trace state member. + * If the type is {@link eTraceStateKeyType.deleted} then the value should be ignored + * and will likely be undefined. + */ + readonly value?: string; +} + +/** + * @internal + * Parse a trace state key/value pair + * @param value - the key/value pair as a string + * @returns The trace state member if valid, otherwise null + */ +function _parseListMember(value: string): ITraceStateMember | null { + if (value) { + TRACESTATE_KVP_REGEX.lastIndex = 0; // Reset the regex to ensure we start from the beginning + let match = TRACESTATE_KVP_REGEX.exec(value); + if (match && match.length >= 7 && match[1] && match[6]) { + let type = match[3] ? eTraceStateKeyType.multiTenant : eTraceStateKeyType.simple; + let multiTenant: ITraceStateMultiTenantKey = null; + if (type === eTraceStateKeyType.multiTenant) { + multiTenant = { + tenantId: match[4], + systemId: match[5] + }; + } + let parts: ITraceStateMember = { + type: type, + key: match[2], + multiTenant: multiTenant, + value: match[6] + }; + + return parts; + } + } + + return null; +} + +/** + * @internal + * Parse the trace state list from a string + * @param value - the list of trace states as a string + * @returns An array of trace state members + */ +function _parseTraceStateList(value?: string): ITraceStateMember[] { + let items: ITraceStateMember[] = []; + + if (value) { + let addedKeys: string[] = []; + arrForEach(strSplit(value, ","), (member) => { + let parts = _parseListMember(member); + if (parts) { + // As per the spec, the first occurrence of a key is the one that should be used + // as all new entries are added to the front (left) of the list + if (arrIndexOf(addedKeys, parts.key) === -1) { + items.push(parts); + addedKeys.push(parts.key); + + if (items.length >= MAX_TRACE_STATE_MEMBERS) { + // The trace state list should not exceed 32 members + return -1; + } + } + } + }); + } + + return items; +} + +function _indexOf(items: ITraceStateMember[], key: string): number { + for (let lp = 0; lp < items.length; lp++) { + if (items[lp].key === key) { + return lp; + } + } + + return -1; +} + +function _keys(items: ITraceStateMember[], parent?: IW3cTraceState | null): string[] { + let keys: string[] = []; + let delKeys: string[] = []; + arrForEach(items, (member) => { + if (member.value != null) { + keys.push(member.key); + } else { + delKeys.push(member.key); + } + }); + + if (parent) { + // Get and add parent keys that are not in the current list or marked as deleted + arrForEach(parent.keys, (key) => { + if (arrIndexOf(keys, key) === -1 && arrIndexOf(delKeys, key) === -1) { + keys.push(key); + } + }); + } + + return keys; +} + +/** + * @internal + * Identifies if the provided items are empty, meaning it has no keys or values. + * @param items - The items to check + * @param parent - The parent trace state to check for keys + * @returns true if the items are empty, false otherwise + */ +function _isEmpty(items: ITraceStateMember[], parent?: IW3cTraceState | null): boolean { + let delKeys: string[]; + let isEmpty = true; + + if (items && items.length > 0) { + arrForEach(items, (member) => { + if (member.value != null) { + isEmpty = false; + } else { + if (!delKeys) { + delKeys = []; + } + + // If the value is null then this is a deleted key, so we can ignore it + delKeys.push(member.key); + } + }); + } + + if (isEmpty && parent) { + isEmpty = parent.isEmpty; + if (!isEmpty && delKeys && delKeys.length > 0) { + // If the parent is not empty then we need to check if any of the keys are in the deleted list + isEmpty = true; + arrForEach(parent.keys, (key) => { + if (arrIndexOf(delKeys, key) === -1) { + isEmpty = false; + return -1; // Break out of the loop + } + }); + } + } + + return isEmpty; +} + +/** + * Identifies if the provided value looks like a distributed trace state instance + * @param value - The value to check + * @returns - True if the value looks like a distributed trace state instance + */ +export function isW3cTraceState(value: any): value is IW3cTraceState { + return !!(value && isArray(value.keys) && isFunction(value.get) && isFunction(value.set) && isFunction(value.del) && isFunction(value.hdrs)); +} + +/** + * Creates a new mutable {@link IW3cTraceState} instance, optionally inheriting from the parent trace state + * and optionally using the provided encoded string value as the initial trace state. + * Calls to {@link IW3cTraceState.set} and {@link IW3cTraceState.del} will modify the current instance + * which means that any child instance that is using this instance as a parent will also be indirectly + * modified unless the child instance has overridden the value associated with the modified key. + * @since 3.4.0 + * @param value - The string value for the trace state + * @param parent - The parent trace state to inherit any existing keys from. + * @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 || []); + + function _get(key: string): string | undefined { + let value: string | undefined; + let theItems = cachedItems.v; + let idx = _indexOf(theItems, key); + if (idx !== -1) { + let itmValue = theItems[idx].value; + if (itmValue != null) { + // Special case for the value being null, which means the key was deleted + value = itmValue; + } + } else if (parent) { + // Get the value from the parent if it exists + value = parent.get(key); + } + + return value; + } + + function _setMember(member: ITraceStateMember): number { + if (member) { + let theItems = cachedItems.v; + let idx = _indexOf(theItems, member.key); + if (idx !== -1) { + // Move the item to the front of the list, removing the previous instance + theItems.splice(idx, 1); + } + + theItems.unshift(member); + // We need to re-create the cached value as during testing the cached lazy value + // may get re-evaluated resetting the items to the original value + cachedItems = createCachedValue(theItems); + + return 0; + } + + return -1; + } + + function _set(key: string, value: string | null): number { + let member: ITraceStateMember | null; + if (key && isString(key) && !isNullOrUndefined(value) && isString(value)) { + member = _parseListMember(key + "=" + value); // Validate the key/value pair before adding it to the state + } + + return _setMember(member); + } + + function _del(key: string) { + _setMember({ + type: eTraceStateKeyType.deleted, + key: key + }); + } + + function _headers(maxHeaders?: number, maxKeys?: number, maxLen?: number): string[] { + let results: string[] = []; + let result = STR_EMPTY; + let numKeys = 0; + let len = 0; + + // Default to the max values if not provided + maxKeys = maxKeys || MAX_TRACE_STATE_MEMBERS; + + // Default to the max length if not provided + maxLen = maxLen || MAX_TRACE_STATE_LEN; + + let theItems = cachedItems.v; + arrForEach(_keys(theItems, parent), (key) => { + let value = _get(key); + if (!isNullOrUndefined(value) && isString(value)) { + numKeys++; + let val = key + "=" + value; + let valLen = val.length; + if (len + 1 + valLen >= maxLen) { + // Don't exceed the max length for any single combined header value + results.push(result); + + if (maxHeaders && results.length <= maxHeaders) { + // Don't exceed the max number of entries + return -1; + } + + result = STR_EMPTY; + len = 0; + } + + if (result.length > 0) { + result += ","; + len++; + } + + result += val; + len += valLen; + + if (numKeys >= maxKeys) { + // Only allow the first maxKeys members + return -1; + } + } + }); + + if (result) { + results.push(result); + } + + return results; + } + + let traceStateList: IW3cTraceState = { + keys: [], + isEmpty: false, + get: _get, + set: _set, + del: _del, + hdrs: _headers, + child: () => createW3cTraceState(null, traceStateList) + }; + + function _toString() { + let headers = traceStateList.hdrs(1); + return headers.length > 0 ? headers[0] : STR_EMPTY; + } + + objDefineProps(traceStateList, { + "keys": { + g: () => _keys(cachedItems.v, parent) + }, + "isEmpty": { + g: () => _isEmpty(cachedItems.v, parent) + }, + "toString": { + v: _toString, + e: false // Do not allow the toString to be enumerated + }, + "_p": { + v: parent, + e: false // Do not allow the parent to be enumerated + } + }); + + + objDefine(traceStateList, getKnownSymbol(WellKnownSymbols.toStringTag), { g: _toString }); + + return traceStateList; +} + +/** + * Create a new independent instance of IW3cTraceState that contains a snapshot of all current key/value pairs + * from the provided instance and any parent instances. The returned instance will have no parent and will be completely + * independent from any future changes to the original instance or its parent chain. + * This is useful when you need to capture the current state and ensure it remains unchanged regardless of + * future modifications to the parent instances. + * @since 3.4.0 + * @param traceState - The trace state instance to snapshot + * @returns A new independent instance of IW3cTraceState with all current key/value pairs captured + */ +export function snapshotW3cTraceState(traceState: IW3cTraceState): IW3cTraceState { + // Create a new independent instance with no parent + // This ensures the returned instance is completely independent from future changes + let snapshot = createW3cTraceState(null, null); + + if (traceState) { + let theKeys = traceState.keys; + + // Iterate over the keys in reverse order to maintain correct precedence + // Since set() adds items to the front, we need to add them in reverse order + // to preserve the original key ordering where newer keys take precedence + for (let i = theKeys.length - 1; i >= 0; i--) { + let key = theKeys[i]; + let value = traceState.get(key); + if (!isNullOrUndefined(value) && isString(value)) { + // Use the set function to add the key/value pair to the snapshot + // This leverages the existing validation and formatting logic + snapshot.set(key, value); + } + } + } + + return snapshot; +} + +/** + * Helper function to fetch the passed traceparent from the page, looking for it as a meta-tag or a Server-Timing header. + * @since 3.4.0 + * @param selectIdx - If the found value is comma separated which is the preferred entry to select, defaults to the first + * @returns + */ +export function findW3cTraceState(): IW3cTraceState { + const name = "tracestate"; + let traceState: IW3cTraceState = null; + let metaTags = findMetaTags(name); + if (metaTags.length > 0) { + traceState = createW3cTraceState(metaTags.join(",")); + } + + if (!traceState) { + let serverTimings = findNamedServerTimings(name); + if (serverTimings.length > 0) { + traceState = createW3cTraceState(serverTimings.join(",")); + } + } + + return traceState; +} diff --git a/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanContext.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanContext.ts new file mode 100644 index 000000000..3a63557c0 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelSpanContext.ts @@ -0,0 +1,57 @@ +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/IOTelTraceState.ts b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTraceState.ts new file mode 100644 index 000000000..35838ae12 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/interfaces/trace/IOTelTraceState.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Provides an OpenTelemetry compatible Interface for the Open Telemetry Api (1.9.0) TraceState type. + * + * The TraceState is a list of key/value pairs that are used to propagate + * vendor-specific trace information across different distributed tracing systems. + * The TraceState is used to store the state of a trace across different + * distributed tracing systems, and it is used to ensure that the trace information + * is consistent across different systems. + * + * Instances of TraceState are immutable, and the methods on this interface + * return a new instance of TraceState with the updated values. + */ +export interface IOTelTraceState { + /** + * Create a new TraceState which inherits from this TraceState and has the + * given key set. + * The new entry will always be added in the front of the list of states. + * + * @param key - key of the TraceState entry. + * @param value - value of the TraceState entry. + */ + set(key: string, value: string): IOTelTraceState; + + /** + * Return a new TraceState which inherits from this TraceState but does not + * contain the given key. + * + * @param key - the key for the TraceState entry to be removed. + */ + unset(key: string): IOTelTraceState; + + /** + * Returns the value to which the specified key is mapped, or `undefined` if + * this map contains no mapping for the key. + * + * @param key - with which the specified value is to be associated. + * @returns the value to which the specified key is mapped, or `undefined` if + * this map contains no mapping for the key. + */ + get(key: string): string | undefined; + + /** + * Serializes the TraceState to 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`. + * + * If the resulting serialization is limited to no longer than 512 bytes, if the combination of + * keys and values exceeds this limit, the serialization will be truncated to the last key/value pair + * that fits within the limit. The serialization will be returned as a string. + * + * This is different from the {@link IW3cTraceState} serialization which returns an array of strings where each + * string is limited to 512 bytes and the array is limited to 32 strings. Thus the OpenTelemetry serialization + * will only return the first single string that fits within the limie. + * + * @returns the serialized string. + */ + serialize(): string; +} \ No newline at end of file diff --git a/shared/AppInsightsCore/src/OpenTelemetry/trace/spanContext.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/spanContext.ts new file mode 100644 index 000000000..0a9bf16ae --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/spanContext.ts @@ -0,0 +1,65 @@ +// 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/traceState.ts b/shared/AppInsightsCore/src/OpenTelemetry/trace/traceState.ts new file mode 100644 index 000000000..a4586a8b3 --- /dev/null +++ b/shared/AppInsightsCore/src/OpenTelemetry/trace/traceState.ts @@ -0,0 +1,103 @@ +import { ICachedValue, createCachedValue, isFunction, isString, objDefine, symbolFor } from "@nevware21/ts-utils"; +import { IW3cTraceState } from "../../JavaScriptSDK.Interfaces/IW3cTraceState"; +import { STR_EMPTY } from "../../JavaScriptSDK/InternalConstants"; +import { createW3cTraceState, isW3cTraceState } from "../../JavaScriptSDK/W3cTraceState"; +import { IOTelTraceState } from "../interfaces/trace/IOTelTraceState"; + +let _otelTraceState: ICachedValue; + +function _initOTelTraceStateSymbol() { + if (!_otelTraceState) { + _otelTraceState = createCachedValue(symbolFor("otTraceState")); + } + + return _otelTraceState; +} + +function _createOTelTraceState(traceState: IW3cTraceState): IOTelTraceState { + if (!_otelTraceState) { + _otelTraceState = _initOTelTraceStateSymbol(); + } + + let otTraceState = { + set: (key: string, value: string): IOTelTraceState => { + let newState = createW3cTraceState(STR_EMPTY, traceState); + newState.set(key, value); + return _createOTelTraceState(newState); + }, + unset: (key: string): IOTelTraceState => { + let newState = createW3cTraceState(STR_EMPTY, traceState); + newState.del(key); + return _createOTelTraceState(newState); + }, + get: (key: string): string | undefined => { + return traceState.get(key); + }, + serialize: (): string => { + let headers = traceState.hdrs(1); + if (headers.length > 0) { + return headers[0]; + } + + return STR_EMPTY; + } + }; + + objDefine(otTraceState as any, _otelTraceState.v, { g: () => traceState }); + + return otTraceState; +} + +/** + * Identifies if the provided value is an OpenTelemetry TraceState object + * @param value - The value to check + * @returns true if the value is an OpenTelemetry TraceState object otherwise false + * @remarks The OpenTelemetry TraceState is an immutable object, meaning that any changes made to the trace state will + * @since 3.4.0 + */ +export function isOTelTraceState(value: any): value is IOTelTraceState { + if (!_otelTraceState) { + _otelTraceState = _initOTelTraceStateSymbol(); + } + + if (value && value[_otelTraceState.v]) { + return true; + } + + return value && isFunction(value.serialize) && isFunction(value.unset)&& isFunction(value.get)&& isFunction(value.set); +} + +/** + * Returns an OpenTelemetry compatible instance of the trace state, an important distinction + * between an {@link IW3cTraceState} and an {@link IOTelTraceState} is that the OpenTelemetry version + * is immutable, meaning that any changes made to the trace state will create and return a new + * instance for the {@link IOTelTraceState.set} and {@link IOTelTraceState.unset} methods. + * @param value - The current trace state value + * @returns An OpenTelemetry compatible instance of the trace state + * @remarks The OpenTelemetry TraceState is an immutable object, meaning that any changes made to the trace state will + * @since 3.4.0 + */ +export function createOTelTraceState(value?: string | IW3cTraceState | IOTelTraceState | null): IOTelTraceState { + let traceState: IW3cTraceState | null = null; + if (isOTelTraceState(value)) { + let parentTraceState: IW3cTraceState; + if (_otelTraceState) { + // Only attempt the lookup if the symbol has been created and therefore possibly + // assigned to a parent trace state + parentTraceState = (value as any)[_otelTraceState.v] as IW3cTraceState; + } + if (parentTraceState) { + // Reuse the existing trace state as a parent to avoid copying objects + traceState = createW3cTraceState(STR_EMPTY, parentTraceState); + } else { + // fallback to creating a new trace state + traceState = createW3cTraceState(value.serialize()); + } + } else if (isW3cTraceState(value)) { + traceState = value; + } else { + traceState = createW3cTraceState(isString(value) ? value : STR_EMPTY); + } + + return _createOTelTraceState(traceState); +} diff --git a/shared/AppInsightsCore/src/applicationinsights-core-js.ts b/shared/AppInsightsCore/src/applicationinsights-core-js.ts index 12a4be475..2d4a13df9 100644 --- a/shared/AppInsightsCore/src/applicationinsights-core-js.ts +++ b/shared/AppInsightsCore/src/applicationinsights-core-js.ts @@ -81,7 +81,7 @@ export { ProcessTelemetryContext, createProcessTelemetryContext // Explicitly NOT exporting createProcessTelemetryUnloadContext() and createProcessTelemetryUpdateContext() as these should only be created internally } from "./JavaScriptSDK/ProcessTelemetryContext"; -export { initializePlugins, sortPlugins, unloadComponents } from "./JavaScriptSDK/TelemetryHelpers"; +export { initializePlugins, sortPlugins, unloadComponents, createDistributedTraceContext } 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"; @@ -99,7 +99,8 @@ export { ITelemetryUnloadState } from "./JavaScriptSDK.Interfaces/ITelemetryUnlo export { IDistributedTraceContext } from "./JavaScriptSDK.Interfaces/IDistributedTraceContext"; export { ITraceParent } from "./JavaScriptSDK.Interfaces/ITraceParent"; export { - createTraceParent, parseTraceParent, isValidTraceId, isValidSpanId, isValidTraceParent, isSampledFlag, formatTraceParent, findW3cTraceParent, findAllScripts + createTraceParent, parseTraceParent, isValidTraceId, isValidSpanId, isValidTraceParent, isSampledFlag, formatTraceParent, findW3cTraceParent, + findAllScripts } from "./JavaScriptSDK/W3cTraceParent"; // Dynamic Config definitions @@ -109,4 +110,22 @@ export { IDynamicPropertyHandler } from "./Config/IDynamicPropertyHandler"; export { IWatchDetails, IWatcherHandler, WatcherFunction } from "./Config/IDynamicWatcher"; export { createDynamicConfig, onConfigChange } from "./Config/DynamicConfig"; export { getDynamicConfigHandler, blockDynamicConversion, forceDynamicConversion } from "./Config/DynamicSupport"; -export { cfgDfValidate, cfgDfMerge, cfgDfBoolean, cfgDfFunc, cfgDfString, cfgDfSet, cfgDfBlockPropValue } from "./Config/ConfigDefaultHelpers"; \ No newline at end of file +export { cfgDfValidate, cfgDfMerge, cfgDfBoolean, cfgDfFunc, cfgDfString, cfgDfSet, cfgDfBlockPropValue } from "./Config/ConfigDefaultHelpers"; + +// W3c TraceState support +export { eW3CTraceFlags } from "./JavaScriptSDK.Enums/W3CTraceFlags"; +export { IW3cTraceState } from "./JavaScriptSDK.Interfaces/IW3cTraceState"; +export { createW3cTraceState, findW3cTraceState, isW3cTraceState, snapshotW3cTraceState } from "./JavaScriptSDK/W3cTraceState"; + +// ========================================================================== +// OpenTelemetry exports +// ========================================================================== + + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +// Trace +export { IOTelTraceState } from "./OpenTelemetry/interfaces/trace/IOTelTraceState"; +export { IOTelSpanContext } from "./OpenTelemetry/interfaces/trace/IOTelSpanContext"; diff --git a/tools/rollup-es5/src/ImportCheck.ts b/tools/rollup-es5/src/ImportCheck.ts index 30cc6fecb..296fc705c 100644 --- a/tools/rollup-es5/src/ImportCheck.ts +++ b/tools/rollup-es5/src/ImportCheck.ts @@ -24,7 +24,10 @@ export function importCheck(options:IImportCheckRollupOptions = {}) { checkOptions.keywords.push({ funcNames: [ /(\w[\d\w]*)\[\1\.(\w[\w\d]*)\]/g ], errorMsg: "Incorrect usage of an indexed map lookup detected - [%funcName%] you should use the enum name value as the lookup not the map name -- eg. Name[eName.xxxx]", - errorTitle: "Incorrect usage of indexed map lookup" + errorTitle: "Incorrect usage of indexed map lookup", + ignoreIds: [ + "tslib.es6" // tslib.es6 library has a pre existence check before usage + ] }); From 70753c863f33a9e82023a6cc1c85b9a48dd776a1 Mon Sep 17 00:00:00 2001 From: Nev Wylie <54870357+MSNev@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:58:53 -0700 Subject: [PATCH 2/2] Minification improvements --- channels/1ds-post-js/src/ClockSkewManager.ts | 176 ++++--- channels/1ds-post-js/src/HttpManager.ts | 12 +- channels/1ds-post-js/src/KillSwitch.ts | 114 +++-- .../test/Unit/src/KillSwitchTest.ts | 18 +- .../src/JavaScriptSDK/AnalyticsPlugin.ts | 28 +- .../Telemetry/PageViewManager.ts | 460 +++++++++--------- .../Telemetry/PageViewPerformanceManager.ts | 235 +++++---- .../Telemetry/PageVisitTimeManager.ts | 211 ++++---- .../src/JavaScriptSDK/Timing.ts | 40 +- 9 files changed, 645 insertions(+), 649 deletions(-) diff --git a/channels/1ds-post-js/src/ClockSkewManager.ts b/channels/1ds-post-js/src/ClockSkewManager.ts index 2a2b1b2b5..082115e6d 100644 --- a/channels/1ds-post-js/src/ClockSkewManager.ts +++ b/channels/1ds-post-js/src/ClockSkewManager.ts @@ -4,124 +4,114 @@ * @copyright Microsoft 2018 */ -import dynamicProto from "@microsoft/dynamicproto-js"; - /** -* Class to manage clock skew correction. -*/ -export class ClockSkewManager { - - constructor() { - let _allowRequestSending = true; - let _shouldAddClockSkewHeaders = true; - let _isFirstRequest = true; - let _clockSkewHeaderValue = "use-collector-delta"; - let _clockSkewSet = false; - - dynamicProto(ClockSkewManager, this, (_self) => { - /** - * Determine if requests can be sent. - * @returns True if requests can be sent, false otherwise. - */ - _self.allowRequestSending = (): boolean => { - return _allowRequestSending; - }; - - /** - * Tells the ClockSkewManager that it should assume that the first request has now been sent, - * If this method had not yet been called AND the clock Skew had not been set this will set - * allowRequestSending to false until setClockSet() is called. - */ - _self.firstRequestSent = () => { - if (_isFirstRequest) { - _isFirstRequest = false; - if (!_clockSkewSet) { - // Block sending until we get the first clock Skew - _allowRequestSending = false; - } - } - }; - - /** - * Determine if clock skew headers should be added to the request. - * @returns True if clock skew headers should be added, false otherwise. - */ - _self.shouldAddClockSkewHeaders = (): boolean => { - return _shouldAddClockSkewHeaders; - }; - - /** - * Gets the clock skew header value. - * @returns The clock skew header value. - */ - _self.getClockSkewHeaderValue = (): string => { - return _clockSkewHeaderValue; - }; - - /** - * Sets the clock skew header value. Once clock skew is set this method - * is no-op. - * @param timeDeltaInMillis - Time delta to be saved as the clock skew header value. - */ - _self.setClockSkew = (timeDeltaInMillis?: string) => { - if (!_clockSkewSet) { - if (timeDeltaInMillis) { - _clockSkewHeaderValue = timeDeltaInMillis; - _shouldAddClockSkewHeaders = true; - _clockSkewSet = true; - } else { - _shouldAddClockSkewHeaders = false; - } - - // Unblock sending - _allowRequestSending = true; - } - }; - }); - } - + * Internal interface to manage clock skew correction. + * @internal + */ +export interface IClockSkewManager { /** * Determine if the request can be sent. * @returns True if requests can be sent, false otherwise. */ - public allowRequestSending(): boolean { - // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging - return false; - } + allowRequestSending(): boolean; /** * Tells the ClockSkewManager that it should assume that the first request has now been sent, * If this method had not yet been called AND the clock Skew had not been set this will set * allowRequestSending to false until setClockSet() is called. */ - public firstRequestSent(): void { - // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging - } + firstRequestSent(): void; /** * Determine if clock skew headers should be added to the request. * @returns True if clock skew headers should be added, false otherwise. */ - public shouldAddClockSkewHeaders(): boolean { - // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging - return false; - } + shouldAddClockSkewHeaders(): boolean; /** * Gets the clock skew header value. * @returns The clock skew header value. */ - public getClockSkewHeaderValue(): string { - // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging - return null; - } + getClockSkewHeaderValue(): string; /** * Sets the clock skew header value. Once clock skew is set this method * is no-op. * @param timeDeltaInMillis - Time delta to be saved as the clock skew header value. */ - public setClockSkew(timeDeltaInMillis?: string) { - // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging - } + setClockSkew(timeDeltaInMillis?: string): void; +} + +/** + * Factory function to create a ClockSkewManager instance. + * @returns A new IClockSkewManager instance. + * @internal + */ +export function createClockSkewManager(): IClockSkewManager { + let _allowRequestSending = true; + let _shouldAddClockSkewHeaders = true; + let _isFirstRequest = true; + let _clockSkewHeaderValue = "use-collector-delta"; + let _clockSkewSet = false; + + return { + /** + * Determine if requests can be sent. + * @returns True if requests can be sent, false otherwise. + */ + allowRequestSending: (): boolean => { + return _allowRequestSending; + }, + + /** + * Tells the ClockSkewManager that it should assume that the first request has now been sent, + * If this method had not yet been called AND the clock Skew had not been set this will set + * allowRequestSending to false until setClockSet() is called. + */ + firstRequestSent: () => { + if (_isFirstRequest) { + _isFirstRequest = false; + if (!_clockSkewSet) { + // Block sending until we get the first clock Skew + _allowRequestSending = false; + } + } + }, + + /** + * Determine if clock skew headers should be added to the request. + * @returns True if clock skew headers should be added, false otherwise. + */ + shouldAddClockSkewHeaders: (): boolean => { + return _shouldAddClockSkewHeaders; + }, + + /** + * Gets the clock skew header value. + * @returns The clock skew header value. + */ + getClockSkewHeaderValue: (): string => { + return _clockSkewHeaderValue; + }, + + /** + * Sets the clock skew header value. Once clock skew is set this method + * is no-op. + * @param timeDeltaInMillis - Time delta to be saved as the clock skew header value. + */ + setClockSkew: (timeDeltaInMillis?: string) => { + if (!_clockSkewSet) { + if (timeDeltaInMillis) { + _clockSkewHeaderValue = timeDeltaInMillis; + _shouldAddClockSkewHeaders = true; + _clockSkewSet = true; + } else { + _shouldAddClockSkewHeaders = false; + } + + // Unblock sending + _allowRequestSending = true; + } + } + }; } diff --git a/channels/1ds-post-js/src/HttpManager.ts b/channels/1ds-post-js/src/HttpManager.ts index 1e38692db..d1e86ee14 100644 --- a/channels/1ds-post-js/src/HttpManager.ts +++ b/channels/1ds-post-js/src/HttpManager.ts @@ -14,7 +14,7 @@ import { } from "@microsoft/1ds-core-js"; import { arrAppend, getInst, isFunction } from "@nevware21/ts-utils"; import { BatchNotificationAction, BatchNotificationActions } from "./BatchNotificationActions"; -import { ClockSkewManager } from "./ClockSkewManager"; +import { IClockSkewManager, createClockSkewManager } from "./ClockSkewManager"; import { EventBatchNotificationReason, IChannelConfiguration, ICollectorResult, IPostChannel, IPostTransmissionTelemetryItem, PayloadListenerFunction, PayloadPreprocessorFunction @@ -26,7 +26,7 @@ import { STR_NO_RESPONSE_BODY, STR_OTHER, STR_REQUEUE, STR_RESPONSE_FAIL, STR_SENDING, STR_TIME_DELTA_HEADER, STR_TIME_DELTA_TO_APPLY, STR_UPLOAD_TIME } from "./InternalConstants"; -import { KillSwitch } from "./KillSwitch"; +import { IKillSwitch, createKillSwitch } from "./KillSwitch"; import { retryPolicyGetMillisToBackoffForRetry, retryPolicyShouldRetryForStatus } from "./RetryPolicy"; import { ISerializedPayload, Serializer } from "./Serializer"; import { ITimeoutOverrideWrapper, createTimeoutWrapper } from "./TimeoutOverrideWrapper"; @@ -146,9 +146,9 @@ export class HttpManager { // Only set "Default" values in the _initDefaults() method, unless value are not "reset" during unloading // ------------------------------------------------------------------------------------------------------------------------ let _urlString: string; - let _killSwitch: KillSwitch; + let _killSwitch: IKillSwitch; let _paused: boolean; - let _clockSkewManager: ClockSkewManager; + let _clockSkewManager: IClockSkewManager; let _useBeacons = false; let _outstandingRequests: number; // Holds the number of outstanding async requests that have not returned a response yet let _postManager: IPostChannel; @@ -472,9 +472,9 @@ export class HttpManager { function _initDefaults() { let undefValue: undefined; _urlString = null - _killSwitch = new KillSwitch(); + _killSwitch = createKillSwitch(); _paused = false; - _clockSkewManager = new ClockSkewManager(); + _clockSkewManager = createClockSkewManager(); _useBeacons = false; _outstandingRequests = 0; // Holds the number of outstanding async requests that have not returned a response yet _postManager = null diff --git a/channels/1ds-post-js/src/KillSwitch.ts b/channels/1ds-post-js/src/KillSwitch.ts index 6f8a161d9..4cf41ca70 100644 --- a/channels/1ds-post-js/src/KillSwitch.ts +++ b/channels/1ds-post-js/src/KillSwitch.ts @@ -4,81 +4,79 @@ * @copyright Microsoft 2018 */ -import dynamicProto from "@microsoft/dynamicproto-js"; import { arrForEach, dateNow, strTrim } from "@microsoft/1ds-core-js"; const SecToMsMultiplier = 1000; /** -* Class to stop certain tenants sending events. -*/ -export class KillSwitch { - - constructor() { - let _killedTokenDictionary: { [token: string]: number } = {}; - - function _normalizeTenants(values: string[]) { - let result: string[] = []; - if (values) { - arrForEach(values, (value) => { - result.push(strTrim(value)); - }); - } - - return result; - } - - dynamicProto(KillSwitch, this, (_self) => { - _self.setKillSwitchTenants = (killTokens: string, killDuration: string): string[] => { - if (killTokens && killDuration) { - try { - let killedTokens: string[] = _normalizeTenants(killTokens.split(",")); - if (killDuration === "this-request-only") { - return killedTokens; - } - const durationMs = parseInt(killDuration, 10) * SecToMsMultiplier; - for (let i = 0; i < killedTokens.length; ++i) { - _killedTokenDictionary[killedTokens[i]] = dateNow() + durationMs; - } - } catch (ex) { - return []; - } - } - return []; - }; - - _self.isTenantKilled = (tenantToken: string): boolean => { - let killDictionary = _killedTokenDictionary; - let name = strTrim(tenantToken); - if (killDictionary[name] !== undefined && killDictionary[name] > dateNow()) { - return true; - } - delete killDictionary[name]; - return false; - }; - }); - } - + * Internal interface to stop certain tenants sending events. + * @internal + */ +export interface IKillSwitch { /** * Set the tenants that are to be killed along with the duration. If the duration is * a special value identifying that the tokens are too be killed for only this request, then * a array of tokens is returned. - * @param killedTokens - Tokens that are too be marked to be killed. + * @param killTokens - Tokens that are too be marked to be killed. * @param killDuration - The duration for which the tokens are to be killed. * @returns The tokens that are killed only for this given request. */ - public setKillSwitchTenants(killTokens: string, killDuration: string): string[] { - // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging - return []; - } + setKillSwitchTenants(killTokens: string, killDuration: string): string[]; /** * Determing if the given tenant token has been killed for the moment. * @param tenantToken - The token to be checked. * @returns True if token has been killed, false otherwise. */ - public isTenantKilled(tenantToken: string): boolean { - // @DynamicProtoStub - DO NOT add any code as this will be removed during packaging - return false; + isTenantKilled(tenantToken: string): boolean; +} + +function _normalizeTenants(values: string[]) { + let result: string[] = []; + if (values) { + arrForEach(values, (value) => { + result.push(strTrim(value)); + }); } + + return result; +} + +/** + * Factory function to create a KillSwitch instance. + * @returns A new IKillSwitch instance. + * @internal + */ +export function createKillSwitch(): IKillSwitch { + let _killedTokenDictionary: { [token: string]: number } = {}; + + return { + setKillSwitchTenants: (killTokens: string, killDuration: string): string[] => { + if (killTokens && killDuration) { + try { + let killedTokens: string[] = _normalizeTenants(killTokens.split(",")); + if (killDuration === "this-request-only") { + return killedTokens; + } + const durationMs = parseInt(killDuration, 10) * SecToMsMultiplier; + for (let i = 0; i < killedTokens.length; ++i) { + _killedTokenDictionary[killedTokens[i]] = dateNow() + durationMs; + } + } catch (ex) { + return []; + } + } + return []; + }, + + isTenantKilled: (tenantToken: string): boolean => { + let killDictionary = _killedTokenDictionary; + let name = strTrim(tenantToken); + if (killDictionary[name] !== undefined && killDictionary[name] > dateNow()) { + return true; + } + delete killDictionary[name]; + return false; + } + }; } diff --git a/channels/1ds-post-js/test/Unit/src/KillSwitchTest.ts b/channels/1ds-post-js/test/Unit/src/KillSwitchTest.ts index 1baca42cd..575ad8bf9 100644 --- a/channels/1ds-post-js/test/Unit/src/KillSwitchTest.ts +++ b/channels/1ds-post-js/test/Unit/src/KillSwitchTest.ts @@ -1,5 +1,5 @@ import { AITestClass } from "@microsoft/ai-test-framework"; -import { KillSwitch } from '../../../src/KillSwitch'; +import { IKillSwitch, createKillSwitch } from '../../../src/KillSwitch'; export class KillSwitchTest extends AITestClass { @@ -10,7 +10,7 @@ export class KillSwitchTest extends AITestClass { this.testCase({ name: 'check basic kill switch', test: () => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); QUnit.assert.equal(killSwitch.isTenantKilled("test-tenant"), false, "tenant should not be listed"); @@ -33,7 +33,7 @@ export class KillSwitchTest extends AITestClass { name: 'check basic kill switch with expiry', useFakeTimers: true, test: () => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); let theRequest = killSwitch.setKillSwitchTenants("tenant1", "1"); QUnit.assert.equal(theRequest.length, 0); @@ -60,7 +60,7 @@ export class KillSwitchTest extends AITestClass { this.testCase({ name: 'check kill switch for this request only', test: () => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); QUnit.assert.equal(killSwitch.isTenantKilled("test-tenant"), false, "tenant should not be listed"); @@ -76,7 +76,7 @@ export class KillSwitchTest extends AITestClass { this.testCase({ name: 'check multiple tenants kill switch', test: () => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); QUnit.assert.equal(killSwitch.isTenantKilled("test-tenant"), false, "tenant should not be listed"); @@ -93,7 +93,7 @@ export class KillSwitchTest extends AITestClass { name: 'check multiple tenants kill switch with expiry', useFakeTimers: true, test: () => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); let theRequest = killSwitch.setKillSwitchTenants("tenant1,tenant2", "1"); QUnit.assert.equal(theRequest.length, 0); @@ -125,7 +125,7 @@ export class KillSwitchTest extends AITestClass { this.testCase({ name: 'check whitespace kill switch', test: () => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); let tenant1Checks: string[] = [ "tenant1", @@ -156,7 +156,7 @@ export class KillSwitchTest extends AITestClass { ]; tenant1Checks.forEach((tenant1) => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); let theRequest = killSwitch.setKillSwitchTenants(tenant1, "1000"); QUnit.assert.equal(theRequest.length, 0); @@ -184,7 +184,7 @@ export class KillSwitchTest extends AITestClass { ]; tenant1Values.forEach((value) => { - let killSwitch = new KillSwitch(); + let killSwitch = createKillSwitch(); let theRequest = killSwitch.setKillSwitchTenants(value, "this-request-only"); let found = false; diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts index 556d8702c..7e9a870f1 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/AnalyticsPlugin.ts @@ -22,10 +22,10 @@ import { } from "@microsoft/applicationinsights-core-js"; import { isArray, isError, objDeepFreeze, objDefine, scheduleTimeout, strIndexOf } from "@nevware21/ts-utils"; import { IAnalyticsConfig } from "./Interfaces/IAnalyticsConfig"; -import { IAppInsightsInternal, PageViewManager } from "./Telemetry/PageViewManager"; -import { PageViewPerformanceManager } from "./Telemetry/PageViewPerformanceManager"; -import { PageVisitTimeManager } from "./Telemetry/PageVisitTimeManager"; -import { Timing } from "./Timing"; +import { IAppInsightsInternal, IPageViewManager, createPageViewManager } from "./Telemetry/PageViewManager"; +import { IPageViewPerformanceManager, createPageViewPerformanceManager } from "./Telemetry/PageViewPerformanceManager"; +import { IPageVisitTimeManager, createPageVisitTimeManager } from "./Telemetry/PageVisitTimeManager"; +import { ITiming, createTiming } from "./Timing"; const strEvent = "event"; @@ -106,11 +106,11 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights constructor() { super(); - let _eventTracking: Timing; - let _pageTracking: Timing; - let _pageViewManager: PageViewManager; - let _pageViewPerformanceManager: PageViewPerformanceManager; - let _pageVisitTimeManager: PageVisitTimeManager; + let _eventTracking: ITiming; + let _pageTracking: ITiming; + let _pageViewManager: IPageViewManager; + let _pageViewPerformanceManager: IPageViewPerformanceManager; + let _pageVisitTimeManager: IPageVisitTimeManager; let _preInitTelemetryInitializers: TelemetryInitializerFunction[]; let _isBrowserLinkTrackingEnabled: boolean; let _browserLinkInitializerAdded: boolean; @@ -570,11 +570,11 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights _populateDefaults(config); - _pageViewPerformanceManager = new PageViewPerformanceManager(_self.core); - _pageViewManager = new PageViewManager(_self, _extConfig.overridePageViewDuration, _self.core, _pageViewPerformanceManager); - _pageVisitTimeManager = new PageVisitTimeManager(_self.diagLog(), (pageName, pageUrl, pageVisitTime) => trackPageVisitTime(pageName, pageUrl, pageVisitTime)) + _pageViewPerformanceManager = createPageViewPerformanceManager(_self.core); + _pageViewManager = createPageViewManager(_self, _extConfig.overridePageViewDuration, _self.core, _pageViewPerformanceManager); + _pageVisitTimeManager = createPageVisitTimeManager(_self.diagLog(), (pageName, pageUrl, pageVisitTime) => trackPageVisitTime(pageName, pageUrl, pageVisitTime)); - _eventTracking = new Timing(_self.diagLog(), "trackEvent"); + _eventTracking = createTiming(_self.diagLog(), "trackEvent"); _eventTracking.action = (name?: string, url?: string, duration?: number, properties?: { [key: string]: string }, measurements?: { [key: string]: number }) => { if (!properties) { @@ -590,7 +590,7 @@ export class AnalyticsPlugin extends BaseTelemetryPlugin implements IAppInsights } // initialize page view timing - _pageTracking = new Timing(_self.diagLog(), "trackPageView"); + _pageTracking = createTiming(_self.diagLog(), "trackPageView"); _pageTracking.action = (name, url, duration, properties, measurements) => { // duration must be a custom property in order for the collector to extract it diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewManager.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewManager.ts index 13d60fa27..2032518e3 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewManager.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewManager.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import dynamicProto from "@microsoft/dynamicproto-js"; import { IPageViewPerformanceTelemetryInternal, IPageViewTelemetry, IPageViewTelemetryInternal, dateTimeUtilsDuration } from "@microsoft/applicationinsights-common"; @@ -10,7 +9,7 @@ import { arrForEach, dumpObj, eLoggingSeverity, fieldRedaction, getDocument, getExceptionName, getLocation, isNullOrUndefined } from "@microsoft/applicationinsights-core-js"; import { ITimerHandler, getPerformance, isUndefined, isWebWorker, scheduleTimeout } from "@nevware21/ts-utils"; -import { PageViewPerformanceManager } from "./PageViewPerformanceManager"; +import { IPageViewPerformanceManager } from "./PageViewPerformanceManager"; /** * Internal interface to pass appInsights object to subcomponents without coupling @@ -21,256 +20,259 @@ export interface IAppInsightsInternal { } /** - * Class encapsulates sending page views and page view performance telemetry. + * Internal interface for PageViewManager. + * @internal */ -export class PageViewManager { +export interface IPageViewManager { + /** + * Currently supported cases: + * 1) (default case) track page view called with default parameters, overridePageViewDuration = false. Page view is sent with page view performance when navigation timing data is available. + * a. If navigation timing is not supported then page view is sent right away with undefined duration. Page view performance is not sent. + * 2) overridePageViewDuration = true, custom duration provided. Custom duration is used, page view sends right away. + * 3) overridePageViewDuration = true, custom duration NOT provided. Page view is sent right away, duration is time spent from page load till now (or undefined if navigation timing is not supported). + * 4) overridePageViewDuration = false, custom duration is provided. Page view is sent right away with custom duration. + * + * In all cases page view performance is sent once (only for the 1st call of trackPageView), or not sent if navigation timing is not supported. + */ + trackPageView(pageView: IPageViewTelemetry, customProperties?: { [key: string]: any }): void; - constructor( - appInsights: IAppInsightsInternal, - overridePageViewDuration: boolean, - core: IAppInsightsCore, - pageViewPerformanceManager: PageViewPerformanceManager) { + teardown(unloadCtx?: IProcessTelemetryUnloadContext, unloadState?: ITelemetryUnloadState): void; +} - dynamicProto(PageViewManager, this, (_self) => { - let queueTimer: ITimerHandler = null; - let itemQueue: Array<() => boolean> = []; - let pageViewPerformanceSent: boolean = false; - let firstPageViewSent: boolean = false; - let _logger: IDiagnosticLogger; - - if (core) { - _logger = core.logger; - } - - function _flushChannels(isAsync: boolean) { - if (core) { - core.flush(isAsync, () => { - // Event flushed, callback added to prevent promise creation - }); +/** + * Factory function to create a PageViewManager instance. + * @param appInsights - Internal interface to send telemetry + * @param overridePageViewDuration - Whether to override page view duration + * @param core - App Insights core instance + * @param pageViewPerformanceManager - Page view performance manager instance + * @returns A new IPageViewManager instance. + * @internal + */ +export function createPageViewManager( + appInsights: IAppInsightsInternal, + overridePageViewDuration: boolean, + core: IAppInsightsCore, + pageViewPerformanceManager: IPageViewPerformanceManager): IPageViewManager { + + let queueTimer: ITimerHandler = null; + let itemQueue: Array<() => boolean> = []; + let pageViewPerformanceSent: boolean = false; + let firstPageViewSent: boolean = false; + let _logger: IDiagnosticLogger; + + if (core) { + _logger = core.logger; + } + + function _flushChannels(isAsync: boolean) { + if (core) { + core.flush(isAsync, () => { + // Event flushed, callback added to prevent promise creation + }); + } + } + + function _startTimer() { + if (!queueTimer) { + queueTimer = scheduleTimeout((() => { + queueTimer = null; + let allItems = itemQueue.slice(0); + let doFlush = false; + itemQueue = []; + arrForEach(allItems, (item) => { + if (!item()) { + // Not processed so rescheduled + itemQueue.push(item); + } else { + doFlush = true; + } + }); + + if (itemQueue.length > 0) { + _startTimer(); } - } - - function _startTimer() { - if (!queueTimer) { - queueTimer = scheduleTimeout((() => { - queueTimer = null; - let allItems = itemQueue.slice(0); - let doFlush = false; - itemQueue = []; - arrForEach(allItems, (item) => { - if (!item()) { - // Not processed so rescheduled - itemQueue.push(item); - } else { - doFlush = true; - } - }); - - if (itemQueue.length > 0) { - _startTimer(); - } - - if (doFlush) { - // We process at least one item so flush the queue - _flushChannels(true); - } - }), 100); + + if (doFlush) { + // We process at least one item so flush the queue + _flushChannels(true); } - } + }), 100); + } + } + + function _addQueue(cb:() => boolean) { + itemQueue.push(cb); + + _startTimer(); + } - function _addQueue(cb:() => boolean) { - itemQueue.push(cb); + return { + trackPageView: (pageView: IPageViewTelemetry, customProperties?: { [key: string]: any }) => { + let name = pageView.name; + if (isNullOrUndefined(name) || typeof name !== "string") { + let doc = getDocument(); + name = pageView.name = doc && doc.title || ""; + } - _startTimer(); + let uri = pageView.uri; + if (isNullOrUndefined(uri) || typeof uri !== "string") { + let location = getLocation(); + uri = pageView.uri = location && location.href || ""; + } + if (core && core.config){ + uri = pageView.uri = fieldRedaction(pageView.uri, core.config); } + if (!firstPageViewSent){ + let perf = getPerformance(); + // Access the performance timing object + const navigationEntries = (perf && perf.getEntriesByType && perf.getEntriesByType("navigation")); - _self.trackPageView = (pageView: IPageViewTelemetry, customProperties?: { [key: string]: any }) => { - let name = pageView.name; - if (isNullOrUndefined(name) || typeof name !== "string") { - let doc = getDocument(); - name = pageView.name = doc && doc.title || ""; - } - - let uri = pageView.uri; - if (isNullOrUndefined(uri) || typeof uri !== "string") { - let location = getLocation(); - uri = pageView.uri = location && location.href || ""; + // Edge Case the navigation Entries may return an empty array and the timeOrigin is not supported on IE + if (navigationEntries && navigationEntries[0] && !isUndefined(perf.timeOrigin)) { + // Get the value of loadEventStart + const loadEventStart = (navigationEntries[0] as PerformanceNavigationTiming).loadEventStart; + pageView.startTime = new Date(perf.timeOrigin + loadEventStart); + } else { + // calculate the start time manually + let duration = ((customProperties || pageView.properties || {}).duration || 0); + pageView.startTime = new Date(new Date().getTime() - duration); } - if (core && core.config){ - uri = pageView.uri = fieldRedaction(pageView.uri, core.config); - } - if (!firstPageViewSent){ - let perf = getPerformance(); - // Access the performance timing object - const navigationEntries = (perf && perf.getEntriesByType && perf.getEntriesByType("navigation")); + firstPageViewSent = true; + } - // Edge Case the navigation Entries may return an empty array and the timeOrigin is not supported on IE - if (navigationEntries && navigationEntries[0] && !isUndefined(perf.timeOrigin)) { - // Get the value of loadEventStart - const loadEventStart = (navigationEntries[0] as PerformanceNavigationTiming).loadEventStart; - pageView.startTime = new Date(perf.timeOrigin + loadEventStart); - } else { - // calculate the start time manually - let duration = ((customProperties || pageView.properties || {}).duration || 0); - pageView.startTime = new Date(new Date().getTime() - duration); - } - firstPageViewSent = true; - } - - // case 1a. if performance timing is not supported by the browser, send the page view telemetry with the duration provided by the user. If the user - // do not provide the duration, set duration to undefined - // Also this is case 4 - if (!pageViewPerformanceManager.isPerformanceTimingSupported()) { - appInsights.sendPageViewInternal( - pageView, - customProperties - ); - _flushChannels(true); - - if (!isWebWorker()) { - // no navigation timing (IE 8, iOS Safari 8.4, Opera Mini 8 - see http://caniuse.com/#feat=nav-timing) - _throwInternal(_logger, - eLoggingSeverity.WARNING, - _eInternalMessageId.NavigationTimingNotSupported, - "trackPageView: navigation timing API used for calculation of page duration is not supported in this browser. This page view will be collected without duration and timing info."); - } - - return; - } - - let pageViewSent = false; - let customDuration: number; - - // if the performance timing is supported by the browser, calculate the custom duration - const start = pageViewPerformanceManager.getPerformanceTiming().navigationStart; - if (start > 0) { - customDuration = dateTimeUtilsDuration(start, +new Date); - if (!pageViewPerformanceManager.shouldCollectDuration(customDuration)) { - customDuration = undefined; - } + // case 1a. if performance timing is not supported by the browser, send the page view telemetry with the duration provided by the user. If the user + // do not provide the duration, set duration to undefined + // Also this is case 4 + if (!pageViewPerformanceManager.isPerformanceTimingSupported()) { + appInsights.sendPageViewInternal( + pageView, + customProperties + ); + _flushChannels(true); + + if (!isWebWorker()) { + // no navigation timing (IE 8, iOS Safari 8.4, Opera Mini 8 - see http://caniuse.com/#feat=nav-timing) + _throwInternal(_logger, + eLoggingSeverity.WARNING, + _eInternalMessageId.NavigationTimingNotSupported, + "trackPageView: navigation timing API used for calculation of page duration is not supported in this browser. This page view will be collected without duration and timing info."); } - - // if the user has provided duration, send a page view telemetry with the provided duration. Otherwise, if - // overridePageViewDuration is set to true, send a page view telemetry with the custom duration calculated earlier - let duration; - if (!isNullOrUndefined(customProperties) && - !isNullOrUndefined(customProperties.duration)) { - duration = customProperties.duration; + + return; + } + + let pageViewSent = false; + let customDuration: number; + + // if the performance timing is supported by the browser, calculate the custom duration + const start = pageViewPerformanceManager.getPerformanceTiming().navigationStart; + if (start > 0) { + customDuration = dateTimeUtilsDuration(start, +new Date); + if (!pageViewPerformanceManager.shouldCollectDuration(customDuration)) { + customDuration = undefined; } - if (overridePageViewDuration || !isNaN(duration)) { - if (isNaN(duration)) { - // case 3 - if (!customProperties) { - customProperties = {}; - } - - customProperties.duration = customDuration; + } + + // if the user has provided duration, send a page view telemetry with the provided duration. Otherwise, if + // overridePageViewDuration is set to true, send a page view telemetry with the custom duration calculated earlier + let duration; + if (!isNullOrUndefined(customProperties) && + !isNullOrUndefined(customProperties.duration)) { + duration = customProperties.duration; + } + if (overridePageViewDuration || !isNaN(duration)) { + if (isNaN(duration)) { + // case 3 + if (!customProperties) { + customProperties = {}; } - // case 2 - appInsights.sendPageViewInternal( - pageView, - customProperties - ); - _flushChannels(true); - pageViewSent = true; - } - - // now try to send the page view performance telemetry - const maxDurationLimit = 60000; - if (!customProperties) { - customProperties = {}; + + customProperties.duration = customDuration; } - - // Queue the event for processing - _addQueue(() => { - let processed = false; - try { - if (pageViewPerformanceManager.isPerformanceTimingDataReady()) { - processed = true; - const pageViewPerformance: IPageViewPerformanceTelemetryInternal = { - name, - uri - }; - pageViewPerformanceManager.populatePageViewPerformanceEvent(pageViewPerformance); - - if (!pageViewPerformance.isValid && !pageViewSent) { - // If navigation timing gives invalid numbers, then go back to "override page view duration" mode. - // That's the best value we can get that makes sense. - customProperties.duration = customDuration; + // case 2 + appInsights.sendPageViewInternal( + pageView, + customProperties + ); + _flushChannels(true); + pageViewSent = true; + } + + // now try to send the page view performance telemetry + const maxDurationLimit = 60000; + if (!customProperties) { + customProperties = {}; + } + + // Queue the event for processing + _addQueue(() => { + let processed = false; + try { + if (pageViewPerformanceManager.isPerformanceTimingDataReady()) { + processed = true; + const pageViewPerformance: IPageViewPerformanceTelemetryInternal = { + name, + uri + }; + pageViewPerformanceManager.populatePageViewPerformanceEvent(pageViewPerformance); + + if (!pageViewPerformance.isValid && !pageViewSent) { + // If navigation timing gives invalid numbers, then go back to "override page view duration" mode. + // That's the best value we can get that makes sense. + customProperties.duration = customDuration; + appInsights.sendPageViewInternal( + pageView, + customProperties); + } else { + if (!pageViewSent) { + customProperties.duration = pageViewPerformance.durationMs; appInsights.sendPageViewInternal( pageView, customProperties); - } else { - if (!pageViewSent) { - customProperties.duration = pageViewPerformance.durationMs; - appInsights.sendPageViewInternal( - pageView, - customProperties); - } - - if (!pageViewPerformanceSent) { - appInsights.sendPageViewPerformanceInternal(pageViewPerformance, customProperties); - pageViewPerformanceSent = true; - } } - } else if (start > 0 && dateTimeUtilsDuration(start, +new Date) > maxDurationLimit) { - // if performance timings are not ready but we exceeded the maximum duration limit, just log a page view telemetry - // with the maximum duration limit. Otherwise, keep waiting until performance timings are ready - processed = true; - if (!pageViewSent) { - customProperties.duration = maxDurationLimit; - appInsights.sendPageViewInternal( - pageView, - customProperties - ); + + if (!pageViewPerformanceSent) { + appInsights.sendPageViewPerformanceInternal(pageViewPerformance, customProperties); + pageViewPerformanceSent = true; } } - } catch (e) { - _throwInternal(_logger, - eLoggingSeverity.CRITICAL, - _eInternalMessageId.TrackPVFailedCalc, - "trackPageView failed on page load calculation: " + getExceptionName(e), - { exception: dumpObj(e) }); - } - - return processed; - }); - }; - - _self.teardown = (unloadCtx?: IProcessTelemetryUnloadContext, unloadState?: ITelemetryUnloadState) => { - if (queueTimer) { - queueTimer.cancel(); - queueTimer = null; - - let allItems = itemQueue.slice(0); - let doFlush = false; - itemQueue = []; - arrForEach(allItems, (item) => { - if (item()) { - doFlush = true; + } else if (start > 0 && dateTimeUtilsDuration(start, +new Date) > maxDurationLimit) { + // if performance timings are not ready but we exceeded the maximum duration limit, just log a page view telemetry + // with the maximum duration limit. Otherwise, keep waiting until performance timings are ready + processed = true; + if (!pageViewSent) { + customProperties.duration = maxDurationLimit; + appInsights.sendPageViewInternal( + pageView, + customProperties + ); } - }); + } + } catch (e) { + _throwInternal(_logger, + eLoggingSeverity.CRITICAL, + _eInternalMessageId.TrackPVFailedCalc, + "trackPageView failed on page load calculation: " + getExceptionName(e), + { exception: dumpObj(e) }); } - }; + + return processed; + }); + }, - }); - } + teardown: (unloadCtx?: IProcessTelemetryUnloadContext, unloadState?: ITelemetryUnloadState) => { + if (queueTimer) { + queueTimer.cancel(); + queueTimer = null; - /** - * Currently supported cases: - * 1) (default case) track page view called with default parameters, overridePageViewDuration = false. Page view is sent with page view performance when navigation timing data is available. - * a. If navigation timing is not supported then page view is sent right away with undefined duration. Page view performance is not sent. - * 2) overridePageViewDuration = true, custom duration provided. Custom duration is used, page view sends right away. - * 3) overridePageViewDuration = true, custom duration NOT provided. Page view is sent right away, duration is time spent from page load till now (or undefined if navigation timing is not supported). - * 4) overridePageViewDuration = false, custom duration is provided. Page view is sent right away with custom duration. - * - * In all cases page view performance is sent once (only for the 1st call of trackPageView), or not sent if navigation timing is not supported. - */ - public trackPageView(pageView: IPageViewTelemetry, customProperties?: { [key: string]: any }) { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - } - - public teardown(unloadCtx?: IProcessTelemetryUnloadContext, unloadState?: ITelemetryUnloadState) { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - } + let allItems = itemQueue.slice(0); + itemQueue = []; + arrForEach(allItems, (item) => { + if (item()) { + // Item processed successfully + } + }); + } + } + }; } diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewPerformanceManager.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewPerformanceManager.ts index e78cd4ae1..724e01f91 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewPerformanceManager.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageViewPerformanceManager.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import dynamicProto from "@microsoft/dynamicproto-js"; import { IPageViewPerformanceTelemetryInternal, dateTimeUtilsDuration, msToTimeSpan } from "@microsoft/applicationinsights-common"; import { IAppInsightsCore, IDiagnosticLogger, _eInternalMessageId, _throwInternal, eLoggingSeverity, getNavigator, getPerformance, safeGetLogger @@ -83,139 +82,135 @@ function _shouldCollectDuration(...durations: number[]): boolean { } /** - * Class encapsulates sending page view performance telemetry. + * Internal interface for PageViewPerformanceManager. + * @internal */ -export class PageViewPerformanceManager { - - constructor(core: IAppInsightsCore) { - let _logger: IDiagnosticLogger = safeGetLogger(core); - - dynamicProto(PageViewPerformanceManager, this, (_self) => { - _self.populatePageViewPerformanceEvent = (pageViewPerformance: IPageViewPerformanceTelemetryInternal): void => { - pageViewPerformance.isValid = false; - - /* - * http://www.w3.org/TR/navigation-timing/#processing-model - * |-navigationStart - * | |-connectEnd - * | ||-requestStart - * | || |-responseStart - * | || | |-responseEnd - * | || | | - * | || | | |-loadEventEnd - * |---network---||---request---|---response---|---dom---| - * |--------------------------total----------------------| - * - * total = The difference between the load event of the current document is completed and the first recorded timestamp of the performance entry : https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings#duration - * network = Redirect time + App Cache + DNS lookup time + TCP connection time - * request = Request time : https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings#request_time - * response = Response time - * dom = Document load time : https://html.spec.whatwg.org/multipage/dom.html#document-load-timing-info - * = Document processing time : https://developers.google.com/web/fundamentals/performance/navigation-and-resource-timing/#document_processing - * + Loading time : https://developers.google.com/web/fundamentals/performance/navigation-and-resource-timing/#loading - */ - const navigationTiming = _getPerformanceNavigationTiming(); - const timing = _getPerformanceTiming(); - let total = 0; - let network = 0; - let request = 0; - let response = 0; - let dom = 0; - - if (navigationTiming || timing) { - if (navigationTiming) { - total = navigationTiming.duration; - /** - * support both cases: - * - startTime is always zero: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming - * - for older browsers where the startTime is not zero - */ - network = navigationTiming.startTime === 0 ? navigationTiming.connectEnd : dateTimeUtilsDuration(navigationTiming.startTime, navigationTiming.connectEnd); - request = dateTimeUtilsDuration(navigationTiming.requestStart, navigationTiming.responseStart); - response = dateTimeUtilsDuration(navigationTiming.responseStart, navigationTiming.responseEnd); - dom = dateTimeUtilsDuration(navigationTiming.responseEnd, navigationTiming.loadEventEnd); - } else { - total = dateTimeUtilsDuration(timing.navigationStart, timing.loadEventEnd); - network = dateTimeUtilsDuration(timing.navigationStart, timing.connectEnd); - request = dateTimeUtilsDuration(timing.requestStart, timing.responseStart); - response = dateTimeUtilsDuration(timing.responseStart, timing.responseEnd); - dom = dateTimeUtilsDuration(timing.responseEnd, timing.loadEventEnd); - } - - if (total === 0) { - _throwInternal(_logger, - eLoggingSeverity.WARNING, - _eInternalMessageId.ErrorPVCalc, - "error calculating page view performance.", - { total, network, request, response, dom }); - - } else if (!_self.shouldCollectDuration(total, network, request, response, dom)) { - _throwInternal(_logger, - eLoggingSeverity.WARNING, - _eInternalMessageId.InvalidDurationValue, - "Invalid page load duration value. Browser perf data won't be sent.", - { total, network, request, response, dom }); - - } else if (total < mathFloor(network) + mathFloor(request) + mathFloor(response) + mathFloor(dom)) { - // some browsers may report individual components incorrectly so that the sum of the parts will be bigger than total PLT - // in this case, don't report client performance from this page - _throwInternal(_logger, - eLoggingSeverity.WARNING, - _eInternalMessageId.ClientPerformanceMathError, - "client performance math error.", - { total, network, request, response, dom }); - - } else { - pageViewPerformance.durationMs = total; - // // convert to timespans - pageViewPerformance.perfTotal = pageViewPerformance.duration = msToTimeSpan(total); - pageViewPerformance.networkConnect = msToTimeSpan(network); - pageViewPerformance.sentRequest = msToTimeSpan(request); - pageViewPerformance.receivedResponse = msToTimeSpan(response); - pageViewPerformance.domProcessing = msToTimeSpan(dom); - pageViewPerformance.isValid = true; - } - } - } - - _self.getPerformanceTiming = _getPerformanceTiming; - _self.isPerformanceTimingSupported = _isPerformanceTimingSupported; - _self.isPerformanceTimingDataReady = _isPerformanceTimingDataReady; - _self.shouldCollectDuration = _shouldCollectDuration; - }); - } +export interface IPageViewPerformanceManager { - public populatePageViewPerformanceEvent(pageViewPerformance: IPageViewPerformanceTelemetryInternal): void { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - } + populatePageViewPerformanceEvent(pageViewPerformance: IPageViewPerformanceTelemetryInternal): void; - public getPerformanceTiming(): PerformanceTiming | null { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return null; - } + getPerformanceTiming(): PerformanceTiming | null; /** * Returns true is window performance timing API is supported, false otherwise. */ - public isPerformanceTimingSupported() { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return true; - } + isPerformanceTimingSupported(): boolean; /** * As page loads different parts of performance timing numbers get set. When all of them are set we can report it. * Returns true if ready, false otherwise. */ - public isPerformanceTimingDataReady() { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return true; - } + isPerformanceTimingDataReady(): boolean; /** * This method tells if given durations should be excluded from collection. */ - public shouldCollectDuration(...durations: number[]): boolean { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - return true; - } + shouldCollectDuration(...durations: number[]): boolean; +} + +/** + * Factory function to create a PageViewPerformanceManager instance. + * @param core - App Insights core instance + * @returns A new IPageViewPerformanceManager instance. + * @internal + */ +export function createPageViewPerformanceManager(core: IAppInsightsCore): IPageViewPerformanceManager { + let _logger: IDiagnosticLogger = safeGetLogger(core); + + return { + populatePageViewPerformanceEvent: (pageViewPerformance: IPageViewPerformanceTelemetryInternal): void => { + pageViewPerformance.isValid = false; + + /* + * http://www.w3.org/TR/navigation-timing/#processing-model + * |-navigationStart + * | |-connectEnd + * | ||-requestStart + * | || |-responseStart + * | || | |-responseEnd + * | || | | + * | || | | |-loadEventEnd + * |---network---||---request---|---response---|---dom---| + * |--------------------------total----------------------| + * + * total = The difference between the load event of the current document is completed and the first recorded timestamp of the performance entry : https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings#duration + * network = Redirect time + App Cache + DNS lookup time + TCP connection time + * request = Request time : https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings#request_time + * response = Response time + * dom = Document load time : https://html.spec.whatwg.org/multipage/dom.html#document-load-timing-info + * = Document processing time : https://developers.google.com/web/fundamentals/performance/navigation-and-resource-timing/#document_processing + * + Loading time : https://developers.google.com/web/fundamentals/performance/navigation-and-resource-timing/#loading + */ + const navigationTiming = _getPerformanceNavigationTiming(); + const timing = _getPerformanceTiming(); + let total = 0; + let network = 0; + let request = 0; + let response = 0; + let dom = 0; + + if (navigationTiming || timing) { + if (navigationTiming) { + total = navigationTiming.duration; + /** + * support both cases: + * - startTime is always zero: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming + * - for older browsers where the startTime is not zero + */ + network = navigationTiming.startTime === 0 ? navigationTiming.connectEnd : dateTimeUtilsDuration(navigationTiming.startTime, navigationTiming.connectEnd); + request = dateTimeUtilsDuration(navigationTiming.requestStart, navigationTiming.responseStart); + response = dateTimeUtilsDuration(navigationTiming.responseStart, navigationTiming.responseEnd); + dom = dateTimeUtilsDuration(navigationTiming.responseEnd, navigationTiming.loadEventEnd); + } else { + total = dateTimeUtilsDuration(timing.navigationStart, timing.loadEventEnd); + network = dateTimeUtilsDuration(timing.navigationStart, timing.connectEnd); + request = dateTimeUtilsDuration(timing.requestStart, timing.responseStart); + response = dateTimeUtilsDuration(timing.responseStart, timing.responseEnd); + dom = dateTimeUtilsDuration(timing.responseEnd, timing.loadEventEnd); + } + + if (total === 0) { + _throwInternal(_logger, + eLoggingSeverity.WARNING, + _eInternalMessageId.ErrorPVCalc, + "error calculating page view performance.", + { total, network, request, response, dom }); + + } else if (!_shouldCollectDuration(total, network, request, response, dom)) { + _throwInternal(_logger, + eLoggingSeverity.WARNING, + _eInternalMessageId.InvalidDurationValue, + "Invalid page load duration value. Browser perf data won't be sent.", + { total, network, request, response, dom }); + + } else if (total < mathFloor(network) + mathFloor(request) + mathFloor(response) + mathFloor(dom)) { + // some browsers may report individual components incorrectly so that the sum of the parts will be bigger than total PLT + // in this case, don't report client performance from this page + _throwInternal(_logger, + eLoggingSeverity.WARNING, + _eInternalMessageId.ClientPerformanceMathError, + "client performance math error.", + { total, network, request, response, dom }); + + } else { + pageViewPerformance.durationMs = total; + // // convert to timespans + pageViewPerformance.perfTotal = pageViewPerformance.duration = msToTimeSpan(total); + pageViewPerformance.networkConnect = msToTimeSpan(network); + pageViewPerformance.sentRequest = msToTimeSpan(request); + pageViewPerformance.receivedResponse = msToTimeSpan(response); + pageViewPerformance.domProcessing = msToTimeSpan(dom); + pageViewPerformance.isValid = true; + } + } + }, + + getPerformanceTiming: _getPerformanceTiming, + + isPerformanceTimingSupported: _isPerformanceTimingSupported, + + isPerformanceTimingDataReady: _isPerformanceTimingDataReady, + + shouldCollectDuration: _shouldCollectDuration + }; } diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts index 17ee521a9..403d901ad 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Telemetry/PageVisitTimeManager.ts @@ -1,117 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import dynamicProto from "@microsoft/dynamicproto-js"; import { utlCanUseSessionStorage, utlGetSessionStorage, utlRemoveSessionStorage, utlSetSessionStorage } from "@microsoft/applicationinsights-common"; import { IDiagnosticLogger, _warnToConsole, dateNow, dumpObj, getJSON, hasJSON, throwError } from "@microsoft/applicationinsights-core-js"; -import { objDefine } from "@nevware21/ts-utils"; - -/** - * Used to track page visit durations - */ -export class PageVisitTimeManager { - - /** - * Creates a new instance of PageVisitTimeManager - * @param pageVisitTimeTrackingHandler - Delegate that will be called to send telemetry data to AI (when trackPreviousPageVisit is called) - * @returns {} - */ - constructor(logger: IDiagnosticLogger, pageVisitTimeTrackingHandler: (pageName: string, pageUrl: string, pageVisitTime: number) => void) { - let prevPageVisitDataKeyName: string = "prevPageVisitData"; - - dynamicProto(PageVisitTimeManager, this, (_self) => { - _self.trackPreviousPageVisit = (currentPageName: string, currentPageUrl: string) => { - - try { - // Restart timer for new page view - const prevPageVisitTimeData = restartPageVisitTimer(currentPageName, currentPageUrl); - - // If there was a page already being timed, track the visit time for it now. - if (prevPageVisitTimeData) { - pageVisitTimeTrackingHandler(prevPageVisitTimeData.pageName, prevPageVisitTimeData.pageUrl, prevPageVisitTimeData.pageVisitTime); - } - } catch (e) { - _warnToConsole(logger, "Auto track page visit time failed, metric will not be collected: " + dumpObj(e)); - } - }; - - /** - * Stops timing of current page (if exists) and starts timing for duration of visit to pageName - * @param pageName - Name of page to begin timing visit duration - * @returns {IPageVisitData} Page visit data (including duration) of pageName from last call to start or restart, if exists. Null if not. - */ - function restartPageVisitTimer(pageName: string, pageUrl: string) { - let prevPageVisitData: IPageVisitData = null; - try { - prevPageVisitData = stopPageVisitTimer(); - if (utlCanUseSessionStorage()) { - if (utlGetSessionStorage(logger, prevPageVisitDataKeyName) != null) { - throwError("Cannot call startPageVisit consecutively without first calling stopPageVisit"); - } - - const currPageVisitDataStr = getJSON().stringify(createPageVisitData(pageName, pageUrl)); - utlSetSessionStorage(logger, prevPageVisitDataKeyName, currPageVisitDataStr); - } - - } catch (e) { - _warnToConsole(logger, "Call to restart failed: " + dumpObj(e)); - prevPageVisitData = null; - } - - return prevPageVisitData; - } - - /** - * Stops timing of current page, if exists. - * @returns {IPageVisitData} Page visit data (including duration) of pageName from call to start, if exists. Null if not. - */ - function stopPageVisitTimer() { - let prevPageVisitData: IPageVisitData = null; - try { - if (utlCanUseSessionStorage()) { - - // Define end time of page's visit - const pageVisitEndTime = dateNow(); - - // Try to retrieve page name and start time from session storage - const pageVisitDataJsonStr = utlGetSessionStorage(logger, prevPageVisitDataKeyName); - if (pageVisitDataJsonStr && hasJSON()) { - - // if previous page data exists, set end time of visit - prevPageVisitData = getJSON().parse(pageVisitDataJsonStr); - prevPageVisitData.pageVisitTime = pageVisitEndTime - prevPageVisitData.pageVisitStartTime; - - // Remove data from storage since we already used it - utlRemoveSessionStorage(logger, prevPageVisitDataKeyName); - } - } - } catch (e) { - _warnToConsole(logger, "Stop page visit timer failed: " + dumpObj(e)); - prevPageVisitData = null; - } - - return prevPageVisitData; - } - - // For backward compatibility - objDefine(_self, "_logger", { g: () => logger }); - objDefine(_self, "pageVisitTimeTrackingHandler", { g: () => pageVisitTimeTrackingHandler}); - }); - } - - /** - * Tracks the previous page visit time telemetry (if exists) and starts timing of new page visit time - * @param currentPageName - Name of page to begin timing for visit duration - * @param currentPageUrl - Url of page to begin timing for visit duration - */ - public trackPreviousPageVisit(currentPageName: string, currentPageUrl: string): void { - // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging - } -} export interface IPageVisitData { pageName: string; @@ -134,3 +29,109 @@ export function createPageVisitData(pageName: string, pageUrl: string): IPageVis pageVisitTime: 0 }; } + +/** + * Internal interface for PageVisitTimeManager. + * @internal + */ +export interface IPageVisitTimeManager { + /** + * Tracks the previous page visit time telemetry (if exists) and starts timing of new page visit time + * @param currentPageName - Name of page to begin timing for visit duration + * @param currentPageUrl - Url of page to begin timing for visit duration + */ + trackPreviousPageVisit(currentPageName: string, currentPageUrl: string): void; + + // These properties are exposed for backward compatibility with tests + readonly _logger?: IDiagnosticLogger; + readonly pageVisitTimeTrackingHandler?: (pageName: string, pageUrl: string, pageVisitTime: number) => void; +} + +/** + * Factory function to create a PageVisitTimeManager instance. + * @param logger - Diagnostic logger + * @param pageVisitTimeTrackingHandler - Delegate that will be called to send telemetry data to AI (when trackPreviousPageVisit is called) + * @returns A new IPageVisitTimeManager instance. + * @internal + */ +export function createPageVisitTimeManager(logger: IDiagnosticLogger, pageVisitTimeTrackingHandler: (pageName: string, pageUrl: string, pageVisitTime: number) => void): IPageVisitTimeManager { + let prevPageVisitDataKeyName: string = "prevPageVisitData"; + + /** + * Stops timing of current page (if exists) and starts timing for duration of visit to pageName + * @param pageName - Name of page to begin timing visit duration + * @returns {IPageVisitData} Page visit data (including duration) of pageName from last call to start or restart, if exists. Null if not. + */ + function restartPageVisitTimer(pageName: string, pageUrl: string) { + let prevPageVisitData: IPageVisitData = null; + try { + prevPageVisitData = stopPageVisitTimer(); + if (utlCanUseSessionStorage()) { + if (utlGetSessionStorage(logger, prevPageVisitDataKeyName) != null) { + throwError("Cannot call startPageVisit consecutively without first calling stopPageVisit"); + } + + const currPageVisitDataStr = getJSON().stringify(createPageVisitData(pageName, pageUrl)); + utlSetSessionStorage(logger, prevPageVisitDataKeyName, currPageVisitDataStr); + } + + } catch (e) { + _warnToConsole(logger, "Call to restart failed: " + dumpObj(e)); + prevPageVisitData = null; + } + + return prevPageVisitData; + } + + /** + * Stops timing of current page, if exists. + * @returns {IPageVisitData} Page visit data (including duration) of pageName from call to start, if exists. Null if not. + */ + function stopPageVisitTimer() { + let prevPageVisitData: IPageVisitData = null; + try { + if (utlCanUseSessionStorage()) { + + // Define end time of page's visit + const pageVisitEndTime = dateNow(); + + // Try to retrieve page name and start time from session storage + const pageVisitDataJsonStr = utlGetSessionStorage(logger, prevPageVisitDataKeyName); + if (pageVisitDataJsonStr && hasJSON()) { + + // if previous page data exists, set end time of visit + prevPageVisitData = getJSON().parse(pageVisitDataJsonStr); + prevPageVisitData.pageVisitTime = pageVisitEndTime - prevPageVisitData.pageVisitStartTime; + + // Remove data from storage since we already used it + utlRemoveSessionStorage(logger, prevPageVisitDataKeyName); + } + } + } catch (e) { + _warnToConsole(logger, "Stop page visit timer failed: " + dumpObj(e)); + prevPageVisitData = null; + } + + return prevPageVisitData; + } + + return { + trackPreviousPageVisit: (currentPageName: string, currentPageUrl: string) => { + try { + // Restart timer for new page view + const prevPageVisitTimeData = restartPageVisitTimer(currentPageName, currentPageUrl); + + // If there was a page already being timed, track the visit time for it now. + if (prevPageVisitTimeData) { + pageVisitTimeTrackingHandler(prevPageVisitTimeData.pageName, prevPageVisitTimeData.pageUrl, prevPageVisitTimeData.pageVisitTime); + } + } catch (e) { + _warnToConsole(logger, "Auto track page visit time failed, metric will not be collected: " + dumpObj(e)); + } + }, + + // Expose for backward compatibility with tests + _logger: logger, + pageVisitTimeTrackingHandler: pageVisitTimeTrackingHandler + }; +} diff --git a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Timing.ts b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Timing.ts index 959d120ab..fde3058b3 100644 --- a/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Timing.ts +++ b/extensions/applicationinsights-analytics-js/src/JavaScriptSDK/Timing.ts @@ -5,19 +5,28 @@ import { dateTimeUtilsDuration } from "@microsoft/applicationinsights-common"; import { IDiagnosticLogger, _eInternalMessageId, _throwInternal, eLoggingSeverity } from "@microsoft/applicationinsights-core-js"; /** - * Used to record timed events and page views. + * Internal interface for Timing. + * @internal */ -export class Timing { - - public action: (name?: string, url?: string, duration?: number, properties?: { [key: string]: string }, measurements?: { [key: string]: number }) => void; - public start: (name: string) => void; - public stop: (name: string, url: string, properties?: { [key: string]: string }, measurements?: { [key: string]: number }) => void; +export interface ITiming { + action: (name?: string, url?: string, duration?: number, properties?: { [key: string]: string }, measurements?: { [key: string]: number }) => void; + start: (name: string) => void; + stop: (name: string, url: string, properties?: { [key: string]: string }, measurements?: { [key: string]: number }) => void; +} - constructor(logger: IDiagnosticLogger, name: string) { - let _self = this; - let _events: { [key: string]: number; } = {} +/** + * Factory function to create a Timing instance. + * @param logger - Diagnostic logger + * @param name - Name identifier for timing operations + * @returns A new ITiming instance. + * @internal + */ +export function createTiming(logger: IDiagnosticLogger, name: string): ITiming { + let _events: { [key: string]: number; } = {} - _self.start = (name: string) => { + const timing: ITiming = { + action: null, // Will be set by the caller + start: (name: string) => { if (typeof _events[name] !== "undefined") { _throwInternal(logger, eLoggingSeverity.WARNING, _eInternalMessageId.StartCalledMoreThanOnce, "start was called more than once for this event without calling stop.", @@ -25,9 +34,8 @@ export class Timing { } _events[name] = +new Date; - } - - _self.stop = (name: string, url: string, properties?: { [key: string]: string }, measurements?: { [key: string]: number }) => { + }, + stop: (name: string, url: string, properties?: { [key: string]: string }, measurements?: { [key: string]: number }) => { const start = _events[name]; if (isNaN(start)) { _throwInternal(logger, @@ -36,11 +44,13 @@ export class Timing { } else { const end = +new Date; const duration = dateTimeUtilsDuration(start, end); - _self.action(name, url, duration, properties, measurements); + timing.action(name, url, duration, properties, measurements); } delete _events[name]; _events[name] = undefined; } - } + }; + + return timing; }