diff --git a/.aiAutoMinify.json b/.aiAutoMinify.json index 5c3314d09..8ba20e49a 100644 --- a/.aiAutoMinify.json +++ b/.aiAutoMinify.json @@ -118,6 +118,15 @@ }, "@microsoft/applicationinsights-osplugin-js": { "constEnums": [] + }, + "@microsoft/applicationinsights-otlpchannel-js": { + "constEnums": [ + "eOtlpSignal", + "eOtlpSpanKind", + "eOtlpStatusCode", + "eOtlpSeverityNumber", + "ePropertyType" + ] } } } \ No newline at end of file diff --git a/RELEASES.md b/RELEASES.md index 69e2ed372..46eb5aa2e 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -4,6 +4,17 @@ +## Unreleased Changes + +### New: OTLP/JSON Channel (`@microsoft/applicationinsights-otlpchannel-js` 0.1.0) + +- Added a new preview channel that converts telemetry to [OTLP/JSON](https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md) in memory and exports it to an OpenTelemetry Protocol (OTLP) HTTP endpoint (`/v1/traces` and `/v1/logs`). It requires no `@opentelemetry/*` dependency and can be used on its own or alongside the existing sender, which forwards every item along the chain (the tee channel is only needed when the channels must be in separate queues). +- Conversion and serialization happen on the `processTelemetry` path as each item is received, and records are buffered pre-grouped by resource and signal with incremental byte accounting, so sending a batch performs no conversion work. This keeps the page unload path as fast as possible. +- `RequestData` / `RemoteDependencyData` / `PageviewData` are exported as spans and `MessageData` / `ExceptionData` / `EventData` / `PageviewPerformanceData` as log records. Native Common Schema spans (`OTelSpan`) are exported as spans directly, preserving their kind, parent, trace state and status. Context tags are promoted onto the OTLP `Resource`; everything else becomes record attributes, with Application Insights specific values namespaced under `microsoft.`. +- Values that the Common Schema marks as PII or customer content are dropped by default (configurable via `piiMode`), since OTLP has no equivalent marker. +- Implements `getOfflineSupport()` so it can be combined with `@microsoft/applicationinsights-offlinechannel-js`. +- Added `examples/otlp`, a multi page test site that runs two independent SDK instances per page against a local mock OTLP collector. It can be driven manually or run headlessly (`npm test`), and validates the OTLP envelope, resource attributes, span/log field validity, nanosecond timestamp precision, attribute well-formedness, and that the two instances stay fully isolated from each other. + ## 3.4.3 (July 2nd, 2026) This is a maintenance release for the 3.4.x version line adding a new SDK statistics feature, a PostChannel reliability fix, and dependency security hardening. The `@microsoft/1ds-post-js` channel is numbered 4.4.3 and requires v3.4.3. diff --git a/channels/otlp-channel-js/.npmignore b/channels/otlp-channel-js/.npmignore new file mode 100644 index 000000000..d5fab6d97 --- /dev/null +++ b/channels/otlp-channel-js/.npmignore @@ -0,0 +1,21 @@ +# NPM Ignore + +# ignore everything +* + +# ... but these files +!package.json +!tsconfig.json +!dist-es*/** +!dist/** +!browser/** +!types/** +!/CODE_OF_CONDUCT.md +!/CONTRIBUTING.md +!/README.md +!/SECURITY.md +!/SUPPORT.md +!/NOTICE +!/PRIVACY +!/LICENSE +!/LICENSE.TXT diff --git a/channels/otlp-channel-js/LICENSE b/channels/otlp-channel-js/LICENSE new file mode 100644 index 000000000..5ae193c94 --- /dev/null +++ b/channels/otlp-channel-js/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/channels/otlp-channel-js/NOTICE b/channels/otlp-channel-js/NOTICE new file mode 100644 index 000000000..e7ab85b06 --- /dev/null +++ b/channels/otlp-channel-js/NOTICE @@ -0,0 +1,17 @@ +NOTICES AND INFORMATION +Do Not Translate or Localize + +This software incorporates material from third parties. Microsoft makes certain +open source code available at https://3rdpartysource.microsoft.com, or you may +send a check or money order for US $5.00, including the product name, the open +source component name, and version number, to: + +Source Code Compliance Team +Microsoft Corporation +One Microsoft Way +Redmond, WA 98052 +USA + +Notwithstanding any other terms, you may reverse engineer this software to the +extent required to debug changes to any libraries licensed under the GNU Lesser +General Public License. diff --git a/channels/otlp-channel-js/PRIVACY b/channels/otlp-channel-js/PRIVACY new file mode 100644 index 000000000..82e01f865 --- /dev/null +++ b/channels/otlp-channel-js/PRIVACY @@ -0,0 +1,3 @@ +# Data Collection + +The software may collect information about you and your use of the software and send it to Microsoft. Microsoft may use this information to provide services and improve our products and services. You may turn off the telemetry as described in the repository. There are also some features in the software that may enable you and Microsoft to collect data from users of your applications. If you use these features, you must comply with applicable law, including providing appropriate notices to users of your applications together with a copy of Microsoft’s privacy statement. Our privacy statement is located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn more about data collection and use in the help documentation and our privacy statement. Your use of the software operates as your consent to these practices. \ No newline at end of file diff --git a/channels/otlp-channel-js/README.md b/channels/otlp-channel-js/README.md new file mode 100644 index 000000000..eeeb8fa89 --- /dev/null +++ b/channels/otlp-channel-js/README.md @@ -0,0 +1,247 @@ +# Microsoft Application Insights JavaScript SDK - OTLP/JSON Channel + +An Application Insights channel that converts telemetry into [OTLP/JSON](https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md) +and exports it to an OpenTelemetry Protocol (OTLP) HTTP endpoint. + +The channel sits at the end of the plugin chain and receives every telemetry item the SDK produces +(`trackEvent`, `trackTrace`, `trackException`, `trackPageView`, `trackDependencyData`, and any spans +created through `startSpan`), converts it in memory, and POSTs it to `/v1/traces` and `/v1/logs`. + +No `@opentelemetry/*` package is required. + +## Getting Started + +### Install + +```bash +npm install --save @microsoft/applicationinsights-otlpchannel-js +``` + +### Basic usage + +A channel must be supplied through the `channels` configuration, not `extensions`. + +```js +import { ApplicationInsights } from "@microsoft/applicationinsights-web"; +import { OtlpChannel } from "@microsoft/applicationinsights-otlpchannel-js"; + +const otlpChannel = new OtlpChannel(); + +const appInsights = new ApplicationInsights({ + config: { + instrumentationKey: "YOUR_INSTRUMENTATION_KEY", + channels: [[ otlpChannel ]], + extensionConfig: { + [otlpChannel.identifier]: { + endpointUrl: "https://your-collector.example.com" + } + } + } +}); + +appInsights.loadAppInsights(); +``` + +### Exporting to both Application Insights and an OTLP collector + +Use the tee channel to send the same telemetry to more than one channel queue. + +```js +import { ApplicationInsights } from "@microsoft/applicationinsights-web"; +import { TeeChannel } from "@microsoft/applicationinsights-teechannel-js"; +import { OtlpChannel } from "@microsoft/applicationinsights-otlpchannel-js"; + +const teeChannel = new TeeChannel(); +const otlpChannel = new OtlpChannel(); + +const appInsights = new ApplicationInsights({ + config: { + instrumentationKey: "YOUR_INSTRUMENTATION_KEY", + channels: [[ teeChannel ], [ otlpChannel ]], + extensionConfig: { + [otlpChannel.identifier]: { + endpointUrl: "https://your-collector.example.com" + } + } + } +}); +``` + +## How it works + +The channel converts each telemetry item into its **final OTLP representation as the item is +received**, not when a batch is sent. Converted records are serialized immediately and appended to +buffers that are already grouped by resource and signal, with the payload size tracked +incrementally. + +Sending a batch is therefore only a string join and an HTTP POST -- no mapping, no attribute +building and no serialization. This matters most during page unload, where the browser gives the +page very little time to finish its work. + +Set `preSerialize: false` to keep the converted objects and serialize the whole payload at send +time instead. The item to OTLP conversion still happens at ingress in that mode. + +## Signal mapping + +| Application Insights `baseType` | OTLP | +| --- | --- | +| `RequestData` | Span, `kind = SERVER` | +| `RemoteDependencyData` | Span, `kind = CLIENT` (or `INTERNAL` for an `InProc` dependency) | +| `PageviewData` | Span, `kind = INTERNAL` (configurable, see `pageViewAs`) | +| `MessageData` | LogRecord | +| `ExceptionData` | LogRecord with the `exception.*` attributes | +| `EventData` | LogRecord with `eventName` | +| `PageviewPerformanceData` | LogRecord | +| `MetricData` | Ignored unless `metricsAsLogs` is enabled (the OTLP metrics signal is not supported yet) | + +Context tags such as `ai.cloud.role`, `ai.cloud.roleInstance` and `ai.application.ver` are promoted +onto the OTLP `Resource` as `service.name`, `service.instance.id` and `service.version`, so they are +not repeated on every record. Everything else -- custom properties, measurements, Part C, the Part A +extensions and the remaining tags -- becomes record attributes. Values that have no OpenTelemetry +semantic convention equivalent are emitted under the `microsoft.` namespace. + +## Configuration + +All values below are supplied under the `OtlpChannel` key of `extensionConfig` and may be changed at +runtime. + +| Name | Default | Description | +| --- | --- | --- | +| `endpointUrl` | | The base OTLP/HTTP endpoint. `/v1/traces` and `/v1/logs` are appended. | +| `tracesEndpointUrl` | | The complete url used to export spans, overrides `endpointUrl`. | +| `logsEndpointUrl` | | The complete url used to export log records, overrides `endpointUrl`. | +| `headers` | | Additional headers added to every request, typically for authentication. | +| `resourceAttributes` | | Additional resource attributes, these override the derived values. | +| `scopeName` | `@microsoft/applicationinsights-web` | The reported instrumentation scope name. | +| `scopeVersion` | package version | The reported instrumentation scope version. | +| `preSerialize` | `true` | Serialize each record as it is received rather than when it is sent. | +| `pageViewAs` | `"span"` | Whether a page view is exported as a `span` or a `log`. | +| `metricsAsLogs` | `false` | Export `MetricData` as log records instead of ignoring it. | +| `piiMode` | `"drop"` | How Common Schema PII / customer content values are handled: `drop`, `keep` or `hash`. | +| `maxBatchSizeInBytes` | `65536` | Send once this many bytes have been buffered. | +| `maxRecordsPerBatch` | `512` | Send once this many records have been buffered. | +| `maxBatchInterval` | `15000` | The maximum time (ms) to buffer records before sending. | +| `eventsLimitInMem` | `10000` | The maximum records held in memory, then the oldest are dropped. | +| `transports` | | The ordered transports to use when sending asynchronously. | +| `unloadTransports` | | The ordered transports to use during page unload. | +| `httpXHROverride` | | A user supplied transport used in preference to the built in transports. | +| `fetchCredentials` | | The `credentials` value used for `fetch` based requests. | +| `disableXhrSync` | `false` | Disable synchronous `XMLHttpRequest` during unload. | +| `disableFetchKeepAlive` | `false` | Disable `fetch` with `keepalive` during unload. | +| `xhrTimeout` | | The timeout (ms) applied to `XMLHttpRequest` based requests. | +| `maxRetryAttempts` | `6` | The maximum retries before a failed batch is discarded. | +| `maxUnloadRetryAttempts` | `2` | The maximum retries while the page is unloading. | +| `disableTelemetry` | `false` | Stop exporting, items still flow down the plugin chain. | +| `consumeEvents` | `false` | Stop passing items to the next plugin once converted. | +| `includeIKeyInResource` | `false` | Include the instrumentation key as a resource attribute. | + +## Chaining after other channels + +The channel calls `processNext`, sorts last by priority, and is discoverable by identifier, so it can +be placed behind other channels in the same channel queue: + +| Channel | Priority | +| --- | --- | +| `TeeChannel` | 999 | +| `OfflineChannel` | 1000 | +| `Sender` (Application Insights) | 1001 | +| `PostChannel` (1DS) | 1011 | +| **`OtlpChannel`** | **1021** | + +A custom SKU can therefore do: + +```js +core.initialize({ + instrumentationKey: "YOUR_KEY", + channels: [[ offlineChannel, otlpChannel ]] +}, []); +``` + +Two things are worth knowing: + +**1. The offline channel must be told about it.** `OfflineChannel` resolves its "online" channel by +identifier from `primaryOnlineChannelId`, which defaults to +`[AppInsightsChannelPlugin, PostChannel]`. In a SKU that has neither, name the OTLP channel +explicitly, otherwise the offline channel finds no online channel and silently persists nothing: + +```js +extensionConfig: { + ["OfflineChannel"]: { primaryOnlineChannelId: [ otlpChannel.identifier ] }, + ["OtlpChannel"]: { endpointUrl: "https://your-collector.example.com" } +} +``` + +Because this channel implements `getOfflineSupport()`, the offline channel will then persist and +replay **OTLP payloads against the OTLP endpoint**. + +**2. A channel that consumes items starves anything after it.** While the browser is offline the +offline channel caches the item and returns *without* calling `processNext`, by design. Anything +chained after it therefore receives nothing until connectivity returns — which is exactly why the +offline channel needs to be pointed at this channel rather than chained in front of it and ignored. + +Set `consumeEvents: true` if this channel is genuinely last and nothing after it should see the item. + +## Privacy + +The Common Schema marks individual fields as PII or customer content, and OTLP has no equivalent +marker. By default (`piiMode: "drop"`) any value carrying such a marker is **omitted** from the +exported payload. Set `piiMode` to `"hash"` to export a stable non reversible hash instead, or to +`"keep"` to export the value along with a `microsoft.pii.` marker attribute so that a +downstream collector can scrub it. + +Note that custom headers cannot be sent using `navigator.sendBeacon`, and that a collector must +allow the CORS preflight that `application/json` with custom headers requires. + +## Retries and partial success + +Requests that fail with `408`, `429`, `500`, `502`, `503`, `504`, or that do not complete at all, are +retried with an exponential backoff (honouring any `Retry-After` header) up to `maxRetryAttempts`. + +A `200` response whose body reports `partialSuccess` means the collector permanently rejected some +records; those are **not** retried. The rejection is logged and reported through the +`eventsDiscarded` notification. + +## Offline support + +`getOfflineSupport()` is implemented, so the channel can be combined with +`@microsoft/applicationinsights-offlinechannel-js` to persist and later replay OTLP payloads. + +## Limitations + +- The OTLP metrics signal (`/v1/metrics`) is not implemented. +- Span events and links are not populated. +- Telemetry created from a span has already been flattened into the Application Insights shape by + the time the channel sees it, so the conversion back to OTLP is not perfectly lossless. Custom + attributes are preserved verbatim through `baseData.properties`. + +## Build + +```bash +npm install +npm run build --silent +``` + +## Test + +```bash +npm run test +``` + +## Data Collection + +As this SDK is designed to enable applications to perform data collection which is sent to the +Microsoft collection endpoints the following is required to identify our privacy statement. + +The software may collect information about you and your use of the software and send it to Microsoft. +Microsoft may use this information to provide services and improve our products and services. You may +turn off the telemetry as described in the repository. There are also some features in the software +that may enable you and Microsoft to collect data from users of your applications. If you use these +features, you must comply with applicable law, including providing appropriate notices to users of +your applications together with a copy of Microsoft's privacy statement. Our privacy statement is +located at https://go.microsoft.com/fwlink/?LinkID=824704. You can learn more about data collection +and use in the help documentation and our privacy statement. Your use of the software operates as +your consent to these practices. + +## License + +[MIT](LICENSE) diff --git a/channels/otlp-channel-js/Tests/Unit/src/Channel.Tests.ts b/channels/otlp-channel-js/Tests/Unit/src/Channel.Tests.ts new file mode 100644 index 000000000..74fd7c02e --- /dev/null +++ b/channels/otlp-channel-js/Tests/Unit/src/Channel.Tests.ts @@ -0,0 +1,700 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { + AppInsightsCore, IPayloadData, IXHROverride, ITelemetryItem, OnCompleteCallback, RequestDataType, TraceDataType +} from "@microsoft/applicationinsights-core-js"; +import { OtlpChannel } from "../../../src/OtlpChannel"; +import { getEndpointUrl, getRetryDelay, parsePartialSuccess } from "../../../src/OtlpHttpSender"; +import { eOtlpSignal } from "../../../src/Enums"; + +const IKEY = "09465199-12AA-4124-817F-544738CC7C41"; +const ENDPOINT = "https://collector.example.com"; + +interface ISentRequest { + payload: IPayloadData; + sync: boolean; +} + +/** + * A transport override that records what was sent and lets each test decide the response. + */ +class TestSender implements IXHROverride { + public requests: ISentRequest[] = []; + public status = 200; + public response = "{}"; + public headers: { [key: string]: string } = {}; + public autoComplete = true; + public pending: OnCompleteCallback[] = []; + + public sendPOST = (payload: IPayloadData, oncomplete: OnCompleteCallback, sync?: boolean) => { + this.requests.push({ payload: payload, sync: !!sync }); + + if (this.autoComplete) { + oncomplete(this.status, this.headers, this.response, payload); + } else { + this.pending.push(oncomplete); + } + }; + + public completeAll(status?: number, response?: string) { + let pending = this.pending; + this.pending = []; + for (let lp = 0; lp < pending.length; lp++) { + pending[lp](status === undefined ? this.status : status, this.headers, + response === undefined ? this.response : response, null); + } + } + + public reset() { + this.requests = []; + this.pending = []; + } + + /** + * Returns every record across every request that was sent. + */ + public allRecords(): any[] { + let records: any[] = []; + for (let lp = 0; lp < this.requests.length; lp++) { + let body = JSON.parse(this.requests[lp].payload.data as string); + let resources = body.resourceSpans || body.resourceLogs || []; + for (let r = 0; r < resources.length; r++) { + let scopes = resources[r].scopeSpans || resources[r].scopeLogs || []; + for (let s = 0; s < scopes.length; s++) { + let items = scopes[s].spans || scopes[s].logRecords || []; + for (let i = 0; i < items.length; i++) { + records.push(items[i]); + } + } + } + } + + return records; + } +} + +function traceItem(message: string): ITelemetryItem { + return { + name: "Microsoft.ApplicationInsights.Message", + iKey: IKEY, + baseType: TraceDataType, + baseData: { message: message } + }; +} + +function requestItem(name: string): ITelemetryItem { + return { + name: "Microsoft.ApplicationInsights.Request", + iKey: IKEY, + baseType: RequestDataType, + baseData: { id: "051581bf3cb55c13", name: name, duration: 10, success: true } + }; +} + +export class OtlpChannelTests extends AITestClass { + + private _core: AppInsightsCore; + private _channel: OtlpChannel; + private _sender: TestSender; + + public testInitialize() { + super.testInitialize(); + this._core = new AppInsightsCore(); + this._channel = new OtlpChannel(); + this._sender = new TestSender(); + } + + public testFinishedCleanup() { + // The core must always be unloaded, otherwise the event handlers and unload hooks it + // registered leak into the next test and the framework validation will fail the run. This + // must happen in testFinishedCleanup (not testCleanup) because the framework validates the + // hooks immediately after testFinishedCleanup returns. + if (this._channel && this._channel.isInitialized()) { + this._channel.pause(); + } + + if (this._core && this._core.isInitialized()) { + this._core.unload(false); + } + + this._core = null; + this._channel = null; + this._sender = null; + + super.testFinishedCleanup(); + } + + private _init(config?: any) { + let extConfig: any = {}; + extConfig[this._channel.identifier] = this._extend({ + endpointUrl: ENDPOINT, + httpXHROverride: this._sender, + maxBatchInterval: 1000 + }, config); + + this._core.initialize({ + instrumentationKey: IKEY, + channels: [[this._channel]], + extensionConfig: extConfig + }, []); + } + + private _extend(target: any, source: any): any { + if (source) { + for (let key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + + return target; + } + + public registerTests() { + + this.testCase({ + name: "The channel initializes as a channel with a unique priority", + test: () => { + this._init(); + + Assert.equal("OtlpChannel", this._channel.identifier, "The identifier"); + Assert.ok(this._channel.priority >= 500, "A channel must have a priority of at least 500"); + Assert.equal(1021, this._channel.priority, "The documented priority"); + Assert.ok(this._channel.isInitialized(), "The channel is initialized"); + } + }); + + this.testCase({ + name: "Telemetry is converted and exported once the batch interval elapses", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.track(traceItem("hello")); + Assert.equal(0, this._sender.requests.length, "Nothing is sent before the interval elapses"); + + this.clock.tick(1001); + + Assert.equal(1, this._sender.requests.length, "A single request is sent"); + let request = this._sender.requests[0]; + Assert.equal(ENDPOINT + "/v1/logs", request.payload.urlString, "A trace is sent to the logs endpoint"); + Assert.equal("application/json", request.payload.headers["Content-Type"], "The content type"); + + let records = this._sender.allRecords(); + Assert.equal(1, records.length, "One record was exported"); + Assert.deepEqual({ stringValue: "hello" }, records[0].body, "The message survived the conversion"); + } + }); + + this.testCase({ + name: "A request item is exported to the traces endpoint", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.track(requestItem("GET /api")); + this.clock.tick(1001); + + Assert.equal(1, this._sender.requests.length, "A single request is sent"); + Assert.equal(ENDPOINT + "/v1/traces", this._sender.requests[0].payload.urlString, + "A request is sent to the traces endpoint"); + } + }); + + this.testCase({ + name: "Spans and logs are sent to their own endpoints in separate requests", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.track(requestItem("GET /api")); + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(2, this._sender.requests.length, "The two signals cannot share a request"); + + let urls = [this._sender.requests[0].payload.urlString, this._sender.requests[1].payload.urlString]; + Assert.notEqual(-1, urls.indexOf(ENDPOINT + "/v1/traces"), "The traces endpoint was used"); + Assert.notEqual(-1, urls.indexOf(ENDPOINT + "/v1/logs"), "The logs endpoint was used"); + } + }); + + this.testCase({ + name: "Reaching the record limit triggers an immediate send", + useFakeTimers: true, + test: () => { + this._init({ maxRecordsPerBatch: 3, maxBatchInterval: 60000 }); + + this._core.track(traceItem("one")); + this._core.track(traceItem("two")); + Assert.equal(0, this._sender.requests.length, "Below the limit nothing is sent"); + + this._core.track(traceItem("three")); + Assert.equal(1, this._sender.requests.length, "Reaching the limit sends without waiting for the timer"); + Assert.equal(3, this._sender.allRecords().length, "All three records were sent"); + } + }); + + this.testCase({ + name: "Reaching the byte limit triggers an immediate send", + useFakeTimers: true, + test: () => { + this._init({ maxBatchSizeInBytes: 200, maxRecordsPerBatch: 1000, maxBatchInterval: 60000 }); + + let sent = false; + for (let lp = 0; lp < 50 && !sent; lp++) { + this._core.track(traceItem("message number " + lp)); + sent = this._sender.requests.length > 0; + } + + Assert.ok(sent, "The byte limit eventually triggers a send"); + } + }); + + this.testCase({ + name: "The buffer is capped and the oldest records are dropped", + useFakeTimers: true, + test: () => { + let discarded = 0; + this._init({ eventsLimitInMem: 5, maxRecordsPerBatch: 1000, maxBatchSizeInBytes: 10000000, + maxBatchInterval: 60000 }); + + this._core.addNotificationListener({ + eventsDiscarded: (items: ITelemetryItem[]) => { + discarded += items.length; + } + }); + + for (let lp = 0; lp < 40; lp++) { + this._core.track(traceItem("message " + lp)); + } + + // The notification manager dispatches eventsDiscarded asynchronously through a 0ms + // timer, so the clock has to be advanced before the listener will have been called. + this.clock.tick(1); + + Assert.ok(discarded > 0, "Records were discarded once the in memory limit was reached"); + } + }); + + this.testCase({ + name: "pause buffers without sending and resume releases the buffer", + useFakeTimers: true, + test: () => { + this._init(); + + this._channel.pause(); + this._core.track(traceItem("hello")); + this.clock.tick(5000); + + Assert.equal(0, this._sender.requests.length, "Nothing is sent while paused"); + + this._channel.resume(); + this.clock.tick(1001); + + Assert.equal(1, this._sender.requests.length, "The buffered record is sent after resuming"); + } + }); + + this.testCase({ + name: "flush sends immediately and invokes the callback", + useFakeTimers: true, + test: () => { + this._init({ maxBatchInterval: 60000 }); + + this._core.track(traceItem("hello")); + Assert.equal(0, this._sender.requests.length, "Nothing is sent before the flush"); + + let completed = false; + this._channel.flush(true, () => { + completed = true; + }); + + Assert.equal(1, this._sender.requests.length, "The flush sent the buffered record"); + Assert.ok(completed, "The callback was invoked"); + } + }); + + this.testCase({ + name: "flush returns a promise when no callback is supplied", + test: () => { + this._init({ maxBatchInterval: 60000 }); + this._core.track(traceItem("hello")); + + let result = this._channel.flush(true); + Assert.ok(!!result, "A result is returned"); + Assert.equal(1, this._sender.requests.length, "The buffered record was sent"); + + return result as any; + } + }); + + this.testCase({ + name: "onunloadFlush sends synchronously", + useFakeTimers: true, + test: () => { + this._init({ maxBatchInterval: 60000 }); + + this._core.track(traceItem("hello")); + this._channel.onunloadFlush(); + + Assert.equal(1, this._sender.requests.length, "The buffered record was sent during unload"); + Assert.ok(this._sender.requests[0].sync, "The unload send is synchronous"); + } + }); + + this.testCase({ + name: "The unload path performs no conversion work", + useFakeTimers: true, + test: () => { + // Every record is converted and serialized as it arrives, so by the time the page is + // unloading the payload is only a string join. Assert that the records really were + // serialized up front rather than at send time. + this._init({ maxBatchInterval: 60000, preSerialize: true }); + + this._core.track(traceItem("hello")); + + let converted = false; + let original = JSON.stringify; + try { + // If the channel were converting at send time it would have to serialize a record + // object here; the only serialization allowed is of the already serialized + // fragments which happens through string concatenation instead. + (JSON as any).stringify = function (value: any) { + converted = true; + return original.apply(JSON, arguments as any); + }; + + this._channel.onunloadFlush(); + } finally { + (JSON as any).stringify = original; + } + + Assert.equal(1, this._sender.requests.length, "The record was still sent"); + Assert.ok(!converted, "No record was serialized on the unload path"); + } + }); + + this.testCase({ + name: "A retryable failure re-queues the batch and it is retried", + useFakeTimers: true, + test: () => { + this._init({ maxBatchInterval: 1000, maxRetryAttempts: 3 }); + this._sender.status = 503; + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(1, this._sender.requests.length, "The first attempt was made"); + + // The retry uses an exponential backoff starting at ~1s (plus jitter) + this._sender.status = 200; + this.clock.tick(30000); + + Assert.ok(this._sender.requests.length > 1, "The batch was retried after the failure"); + } + }); + + this.testCase({ + name: "A non retryable failure discards the batch", + useFakeTimers: true, + test: () => { + let discarded = 0; + this._init({ maxBatchInterval: 1000 }); + this._sender.status = 400; + + this._core.addNotificationListener({ + eventsDiscarded: (items: ITelemetryItem[]) => { + discarded += items.length; + } + }); + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(1, this._sender.requests.length, "One attempt was made"); + + this.clock.tick(60000); + Assert.equal(1, this._sender.requests.length, "A 400 is not retried"); + Assert.equal(1, discarded, "The record was reported as discarded"); + } + }); + + this.testCase({ + name: "A partial success is not retried", + useFakeTimers: true, + test: () => { + this._init({ maxBatchInterval: 1000 }); + this._sender.status = 200; + this._sender.response = "{\"partialSuccess\":{\"rejectedLogRecords\":1,\"errorMessage\":\"bad\"}}"; + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(1, this._sender.requests.length, "One attempt was made"); + + this.clock.tick(60000); + Assert.equal(1, this._sender.requests.length, "A partial success is a success and must not be retried"); + } + }); + + this.testCase({ + name: "disableTelemetry stops the export but items still flow down the chain", + useFakeTimers: true, + test: () => { + this._init({ disableTelemetry: true }); + + this._core.track(traceItem("hello")); + this.clock.tick(5000); + + Assert.equal(0, this._sender.requests.length, "Nothing is exported"); + } + }); + + this.testCase({ + name: "Custom headers are applied to the request", + useFakeTimers: true, + test: () => { + this._init({ headers: { "x-api-key": "secret-value" } }); + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal("secret-value", this._sender.requests[0].payload.headers["x-api-key"], + "The custom header is present"); + Assert.equal("application/json", this._sender.requests[0].payload.headers["Content-Type"], + "The content type is still set"); + } + }); + + this.testCase({ + name: "getOfflineSupport describes how to persist and replay a payload", + test: () => { + this._init(); + + let support = this._channel.getOfflineSupport(); + Assert.ok(!!support, "Offline support is provided"); + Assert.equal(ENDPOINT + "/v1/traces", support.getUrl(), "The url"); + + let serialized = support.serialize(traceItem("hello")); + Assert.ok(!!serialized, "An item can be serialized"); + Assert.ok(serialized.indexOf("hello") !== -1, "The serialized record contains the message"); + + let batched = support.batch([serialized, serialized]); + Assert.equal(2, JSON.parse(batched).length, "Records can be batched and re-parsed"); + + Assert.ok(support.shouldProcess(traceItem("hello")), "A trace is processed"); + Assert.ok(!support.shouldProcess({ name: "x" } as ITelemetryItem), "An item with no baseType is not"); + } + }); + + // --------------------------------------------------------------------------------------- + // Dynamic configuration. The channel memoizes the resource, scope and conversion context for + // performance, so every one of these asserts that the memoized state is invalidated. + // --------------------------------------------------------------------------------------- + + this.testCase({ + name: "Dynamic config: changing endpointUrl redirects subsequent exports", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.track(traceItem("first")); + this.clock.tick(1001); + Assert.equal(ENDPOINT + "/v1/logs", this._sender.requests[0].payload.urlString, "The initial endpoint"); + + this._core.config.extensionConfig[this._channel.identifier].endpointUrl = "https://other.example.com"; + this.clock.tick(1); + + this._sender.reset(); + this._core.track(traceItem("second")); + this.clock.tick(1001); + + Assert.equal("https://other.example.com/v1/logs", this._sender.requests[0].payload.urlString, + "The new endpoint is used"); + } + }); + + this.testCase({ + name: "Dynamic config: changing resourceAttributes invalidates the memoized resource", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.track(traceItem("first")); + this.clock.tick(1001); + + let body = JSON.parse(this._sender.requests[0].payload.data as string); + let attributes = body.resourceLogs[0].resource.attributes; + let found = false; + for (let lp = 0; lp < attributes.length; lp++) { + if (attributes[lp].key === "deployment.environment") { + found = true; + } + } + Assert.ok(!found, "The attribute is not present initially"); + + this._core.config.extensionConfig[this._channel.identifier].resourceAttributes = + { "deployment.environment": "production" }; + this.clock.tick(1); + + this._sender.reset(); + this._core.track(traceItem("second")); + this.clock.tick(1001); + + body = JSON.parse(this._sender.requests[0].payload.data as string); + attributes = body.resourceLogs[0].resource.attributes; + found = false; + for (let lp = 0; lp < attributes.length; lp++) { + if (attributes[lp].key === "deployment.environment" && attributes[lp].value.stringValue === "production") { + found = true; + } + } + + Assert.ok(found, "The memoized resource was rebuilt with the new attribute"); + } + }); + + this.testCase({ + name: "Dynamic config: changing scopeName invalidates the memoized scope", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.config.extensionConfig[this._channel.identifier].scopeName = "my-scope"; + this.clock.tick(1); + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + let body = JSON.parse(this._sender.requests[0].payload.data as string); + Assert.equal("my-scope", body.resourceLogs[0].scopeLogs[0].scope.name, "The new scope name is used"); + } + }); + + this.testCase({ + name: "Dynamic config: changing disableTelemetry takes effect at runtime", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.config.extensionConfig[this._channel.identifier].disableTelemetry = true; + this.clock.tick(1); + + this._core.track(traceItem("hello")); + this.clock.tick(5000); + Assert.equal(0, this._sender.requests.length, "Telemetry is no longer exported"); + + this._core.config.extensionConfig[this._channel.identifier].disableTelemetry = false; + this.clock.tick(1); + + this._core.track(traceItem("hello again")); + this.clock.tick(1001); + Assert.equal(1, this._sender.requests.length, "Telemetry is exported once re-enabled"); + } + }); + + this.testCase({ + name: "Dynamic config: changing pageViewAs changes the signal used", + useFakeTimers: true, + test: () => { + this._init(); + + this._core.config.extensionConfig[this._channel.identifier].pageViewAs = "log"; + this.clock.tick(1); + + this._core.track({ + name: "Microsoft.ApplicationInsights.Pageview", + iKey: IKEY, + baseType: "PageviewData", + baseData: { id: "1", name: "Home", url: "https://example.com/", duration: 100 } + } as ITelemetryItem); + this.clock.tick(1001); + + Assert.equal(ENDPOINT + "/v1/logs", this._sender.requests[0].payload.urlString, + "The page view is now exported as a log"); + } + }); + + this.testCase({ + name: "Dynamic config: changing maxBatchInterval changes the send schedule", + useFakeTimers: true, + test: () => { + this._init({ maxBatchInterval: 60000 }); + + this._core.config.extensionConfig[this._channel.identifier].maxBatchInterval = 500; + this.clock.tick(1); + + this._core.track(traceItem("hello")); + this.clock.tick(501); + + Assert.equal(1, this._sender.requests.length, "The shorter interval is used"); + } + }); + + this.testCase({ + name: "teardown exports anything still buffered and releases the timers", + useFakeTimers: true, + test: () => { + this._init({ maxBatchInterval: 60000 }); + + this._core.track(traceItem("hello")); + Assert.equal(0, this._sender.requests.length, "Nothing has been sent yet"); + + this._core.unload(false); + + Assert.equal(1, this._sender.requests.length, "The buffered record was exported during teardown"); + } + }); + + // --------------------------------------------------------------------------------------- + // Sender helpers + // --------------------------------------------------------------------------------------- + + this.testCase({ + name: "getEndpointUrl appends the signal path and honours the explicit overrides", + test: () => { + Assert.equal("https://c.example.com/v1/traces", getEndpointUrl({ endpointUrl: "https://c.example.com" }, + eOtlpSignal.Span), "The traces path is appended"); + Assert.equal("https://c.example.com/v1/logs", getEndpointUrl({ endpointUrl: "https://c.example.com" }, + eOtlpSignal.Log), "The logs path is appended"); + Assert.equal("https://c.example.com/v1/traces", getEndpointUrl({ endpointUrl: "https://c.example.com/" }, + eOtlpSignal.Span), "A trailing separator does not double up"); + Assert.equal("https://c.example.com/custom", getEndpointUrl( + { endpointUrl: "https://c.example.com", tracesEndpointUrl: "https://c.example.com/custom" }, + eOtlpSignal.Span), "An explicit url is used verbatim"); + Assert.equal("", getEndpointUrl({}, eOtlpSignal.Span), "No endpoint produces an empty url"); + } + }); + + this.testCase({ + name: "parsePartialSuccess extracts the rejected count", + test: () => { + let result = parsePartialSuccess("{\"partialSuccess\":{\"rejectedSpans\":3,\"errorMessage\":\"nope\"}}"); + Assert.equal(3, result.rejected, "The rejected count"); + Assert.equal("nope", result.message, "The message"); + + Assert.equal(0, parsePartialSuccess("{}").rejected, "An empty body reports nothing rejected"); + Assert.equal(0, parsePartialSuccess("not json").rejected, "A non JSON body does not throw"); + Assert.equal(0, parsePartialSuccess(null).rejected, "A missing body does not throw"); + } + }); + + this.testCase({ + name: "getRetryDelay backs off exponentially and honours Retry-After", + test: () => { + let first = getRetryDelay(1); + let second = getRetryDelay(2); + let third = getRetryDelay(3); + + Assert.ok(first >= 1000 && first <= 1250, "The first retry waits about a second"); + Assert.ok(second > first, "The delay grows with each attempt"); + Assert.ok(third > second, "The delay keeps growing"); + Assert.ok(getRetryDelay(20) <= 60000, "The delay is capped"); + + Assert.equal(5000, getRetryDelay(1, "5"), "A numeric Retry-After is used as seconds"); + Assert.equal(60000, getRetryDelay(1, "600"), "A large Retry-After is capped"); + } + }); + } +} diff --git a/channels/otlp-channel-js/Tests/Unit/src/ChannelChain.Tests.ts b/channels/otlp-channel-js/Tests/Unit/src/ChannelChain.Tests.ts new file mode 100644 index 000000000..d278d5572 --- /dev/null +++ b/channels/otlp-channel-js/Tests/Unit/src/ChannelChain.Tests.ts @@ -0,0 +1,294 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { + AppInsightsCore, BaseTelemetryPlugin, IAppInsightsCore, IChannelControls, IConfiguration, IPlugin, + IProcessTelemetryContext, ITelemetryItem, TraceDataType +} from "@microsoft/applicationinsights-core-js"; +import { OtlpChannel } from "../../../src/OtlpChannel"; + +const IKEY = "09465199-12AA-4124-817F-544738CC7C41"; + +/** + * A stand in for another channel in the same queue. Real channels such as the Application Insights + * `Sender` (priority 1001), the 1DS `PostChannel` (1011) and the `OfflineChannel` (1000) all call + * `processNext`, which is what allows a downstream channel to be chained after them. + */ +class ForwardingChannel extends BaseTelemetryPlugin implements IChannelControls { + public identifier: string; + public priority: number; + public received: ITelemetryItem[] = []; + public version = "1.0.0"; + + constructor(identifier: string, priority: number) { + super(); + this.identifier = identifier; + this.priority = priority; + } + + public processTelemetry(item: ITelemetryItem, itemCtx?: IProcessTelemetryContext) { + this.received.push(item); + this.processNext(item, itemCtx); + } + + public pause() {} + public resume() {} + + // Returning true promises the caller that the callback will be invoked, so it must actually be + // invoked or core.unload() waits for a flush that never completes + public flush(isAsync?: boolean, callBack?: (flushComplete?: boolean) => void) { + callBack && callBack(true); + return true; + } +} + +/** + * A channel that consumes the item instead of forwarding it, which is what the offline channel does + * while the browser is offline. + */ +class TerminalChannel extends BaseTelemetryPlugin implements IChannelControls { + public identifier: string; + public priority: number; + public received: ITelemetryItem[] = []; + public version = "1.0.0"; + + constructor(identifier: string, priority: number) { + super(); + this.identifier = identifier; + this.priority = priority; + } + + public processTelemetry(item: ITelemetryItem, itemCtx?: IProcessTelemetryContext) { + this.received.push(item); + // Deliberately does not call processNext + } + + public pause() {} + public resume() {} + + public flush(isAsync?: boolean, callBack?: (flushComplete?: boolean) => void) { + callBack && callBack(true); + return true; + } +} + +class TestSender { + public payloads: any[] = []; + public sendPOST = (payload: any, oncomplete: any) => { + this.payloads.push(payload); + oncomplete(200, {}, "{}", payload); + }; +} + +function traceItem(message: string): ITelemetryItem { + return { + name: "Microsoft.ApplicationInsights.Message", + iKey: IKEY, + baseType: TraceDataType, + baseData: { message: message } + }; +} + +/** + * Verifies that the OTLP channel behaves correctly when a custom SKU places it behind other channels + * in the same channel queue, for example `OfflineChannel -> OtlpChannel`. + */ +export class ChannelChainTests extends AITestClass { + + private _core: AppInsightsCore; + private _otlp: OtlpChannel; + private _sender: TestSender; + + public testInitialize() { + super.testInitialize(); + this._core = new AppInsightsCore(); + this._otlp = new OtlpChannel(); + this._sender = new TestSender(); + } + + public testFinishedCleanup() { + if (this._otlp && this._otlp.isInitialized()) { + this._otlp.pause(); + } + + if (this._core && this._core.isInitialized()) { + this._core.unload(false); + } + + this._core = null; + this._otlp = null; + this._sender = null; + + super.testFinishedCleanup(); + } + + private _init(channels: IChannelControls[], extra?: any) { + let extConfig: any = {}; + extConfig[this._otlp.identifier] = { + endpointUrl: "https://collector.example.com", + httpXHROverride: this._sender, + maxBatchInterval: 1000 + }; + + if (extra) { + for (let key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { + extConfig[key] = extra[key]; + } + } + } + + this._core.initialize({ + instrumentationKey: IKEY, + channels: [channels], + extensionConfig: extConfig + } as IConfiguration, []); + } + + public registerTests() { + + this.testCase({ + name: "The OTLP channel sorts last, after every other shipped channel priority", + test: () => { + // Offline = 1000, Sender = 1001, LocalStorage = 1009, PostChannel = 1011, Tee = 999 + let offline = new ForwardingChannel("OfflineChannel", 1000); + let sender = new ForwardingChannel("AppInsightsChannelPlugin", 1001); + let post = new ForwardingChannel("PostChannel", 1011); + + // Supplied deliberately out of order + this._init([post, this._otlp, offline, sender]); + + let ordered = this._core.getChannels(); + let identifiers: string[] = []; + for (let lp = 0; lp < ordered.length; lp++) { + identifiers.push(ordered[lp].identifier); + } + + Assert.deepEqual( + ["OfflineChannel", "AppInsightsChannelPlugin", "PostChannel", "OtlpChannel"], + identifiers, + "The channels are ordered by priority and the OTLP channel is last"); + } + }); + + this.testCase({ + name: "A custom SKU of OfflineChannel -> OtlpChannel delivers telemetry to the OTLP channel", + useFakeTimers: true, + test: () => { + let offline = new ForwardingChannel("OfflineChannel", 1000); + this._init([offline, this._otlp]); + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(1, offline.received.length, "The offline channel received the item first"); + Assert.equal(1, this._sender.payloads.length, "The OTLP channel exported the item"); + + let body = JSON.parse(this._sender.payloads[0].data); + Assert.equal(1, body.resourceLogs[0].scopeLogs[0].logRecords.length, "One record was exported"); + } + }); + + this.testCase({ + name: "The OTLP channel works behind several chained channels at once", + useFakeTimers: true, + test: () => { + let offline = new ForwardingChannel("OfflineChannel", 1000); + let sender = new ForwardingChannel("AppInsightsChannelPlugin", 1001); + let post = new ForwardingChannel("PostChannel", 1011); + + this._init([offline, sender, post, this._otlp]); + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(1, offline.received.length, "The offline channel saw it"); + Assert.equal(1, sender.received.length, "The sender saw it"); + Assert.equal(1, post.received.length, "The post channel saw it"); + Assert.equal(1, this._sender.payloads.length, "The OTLP channel still exported it"); + } + }); + + this.testCase({ + name: "A preceding channel that consumes the item starves the OTLP channel", + useFakeTimers: true, + test: () => { + // This is exactly what the offline channel does while the browser is offline: it + // caches the item and returns without calling processNext. Anything chained after it + // therefore receives nothing, which is why the offline channel has to be told to + // treat the OTLP channel as its online channel (see primaryOnlineChannelId). + let terminal = new TerminalChannel("OfflineChannel", 1000); + this._init([terminal, this._otlp]); + + this._core.track(traceItem("hello")); + this.clock.tick(5000); + + Assert.equal(1, terminal.received.length, "The upstream channel consumed the item"); + Assert.equal(0, this._sender.payloads.length, + "Nothing reached the OTLP channel, because the item was never forwarded"); + } + }); + + this.testCase({ + name: "The OTLP channel is discoverable by identifier so it can be a primaryOnlineChannelId", + test: () => { + // The offline channel resolves its online channel with core.getPlugin() + // and then calls getOfflineSupport() on it, so both must work for a custom SKU that + // configures `primaryOnlineChannelId: ["OtlpChannel"]`. + let offline = new ForwardingChannel("OfflineChannel", 1000); + this._init([offline, this._otlp]); + + let found = this._core.getPlugin("OtlpChannel"); + Assert.ok(!!found && !!found.plugin, "The OTLP channel is resolvable by identifier"); + Assert.equal("OtlpChannel", found.plugin.identifier, "The identifier matches"); + Assert.ok(found.plugin.isInitialized(), "It reports as initialized"); + Assert.equal("function", typeof found.plugin.getOfflineSupport, + "It exposes getOfflineSupport, which the offline channel requires"); + + let support = found.plugin.getOfflineSupport(); + Assert.ok(!!support, "Offline support is returned"); + Assert.equal("https://collector.example.com/v1/traces", support.getUrl(), + "The offline channel would persist against the OTLP endpoint"); + Assert.ok(!!support.serialize(traceItem("hello")), "An item can be serialized for storage"); + } + }); + + this.testCase({ + name: "Placing the OTLP channel before another channel still forwards the item onward", + useFakeTimers: true, + test: () => { + // The OTLP channel calls processNext, so it never starves anything chained after it + // even if a SKU orders it unusually. + let downstream = new ForwardingChannel("Downstream", 1031); + this._init([this._otlp, downstream]); + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(1, downstream.received.length, + "The channel after the OTLP channel still received the item"); + Assert.equal(1, this._sender.payloads.length, "And the OTLP channel exported it"); + } + }); + + this.testCase({ + name: "consumeEvents stops the OTLP channel forwarding, for SKUs where it is genuinely last", + useFakeTimers: true, + test: () => { + let downstream = new ForwardingChannel("Downstream", 1031); + this._init([this._otlp, downstream], { + ["OtlpChannel"]: { + endpointUrl: "https://collector.example.com", + httpXHROverride: this._sender, + maxBatchInterval: 1000, + consumeEvents: true + } + }); + + this._core.track(traceItem("hello")); + this.clock.tick(1001); + + Assert.equal(0, downstream.received.length, "The item was consumed as configured"); + Assert.equal(1, this._sender.payloads.length, "The OTLP channel still exported it"); + } + }); + } +} diff --git a/channels/otlp-channel-js/Tests/Unit/src/Converter.Tests.ts b/channels/otlp-channel-js/Tests/Unit/src/Converter.Tests.ts new file mode 100644 index 000000000..55f184efc --- /dev/null +++ b/channels/otlp-channel-js/Tests/Unit/src/Converter.Tests.ts @@ -0,0 +1,874 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { + CtxTagKeys, EventDataType, ExceptionDataType, ITelemetryItem, MetricDataType, PageViewDataType, RemoteDependencyDataType, + RequestDataType, TraceDataType, eSeverityLevel +} from "@microsoft/applicationinsights-core-js"; +import { eOtlpSeverityNumber, eOtlpSignal, eOtlpSpanKind, eOtlpStatusCode } from "../../../src/Enums"; +import { IOtlpChannelConfig } from "../../../src/Interfaces/IOtlpChannelConfig"; +import { IOtlpLogRecord, IOtlpSpan } from "../../../src/Interfaces/IOtlpTypes"; +import { IConvertCtx, convertItem, getSignal } from "../../../src/convert/ItemConverter"; +import { buildResourceInfo, getResourceKey, getResourceTagKeys } from "../../../src/convert/ResourceBuilder"; +import { buildPayload, OtlpBatcher } from "../../../src/OtlpBatcher"; + +function createCtx(config?: IOtlpChannelConfig): IConvertCtx { + let theConfig: IOtlpChannelConfig = config || {}; + if (theConfig.piiMode === undefined) { + theConfig.piiMode = "drop"; + } + + // Retain the converted objects so that the tests can assert against the structure directly + theConfig.preSerialize = theConfig.preSerialize === undefined ? false : theConfig.preSerialize; + + return { + config: theConfig, + resourceTagKeys: getResourceTagKeys(), + attrOptions: { piiMode: theConfig.piiMode } + }; +} + +function getAttr(record: any, key: string): any { + let attributes = record && record.attributes; + if (!attributes) { + return undefined; + } + + for (let lp = 0; lp < attributes.length; lp++) { + if (attributes[lp].key === key) { + return attributes[lp].value; + } + } + + return undefined; +} + +function attrKeys(record: any): string[] { + let keys: string[] = []; + let attributes = (record && record.attributes) || []; + for (let lp = 0; lp < attributes.length; lp++) { + keys.push(attributes[lp].key); + } + + return keys; +} + +export class ConverterTests extends AITestClass { + + public registerTests() { + + this.testCase({ + name: "getSignal: routes each baseType to the correct signal", + test: () => { + let config: IOtlpChannelConfig = {}; + Assert.equal(eOtlpSignal.Span, getSignal(RequestDataType, config), "A request is a span"); + Assert.equal(eOtlpSignal.Span, getSignal(RemoteDependencyDataType, config), "A dependency is a span"); + Assert.equal(eOtlpSignal.Log, getSignal(TraceDataType, config), "A trace is a log"); + Assert.equal(eOtlpSignal.Log, getSignal(ExceptionDataType, config), "An exception is a log"); + Assert.equal(eOtlpSignal.Log, getSignal(EventDataType, config), "An event is a log"); + Assert.equal(null, getSignal(MetricDataType, config), "A metric is dropped by default"); + Assert.equal(null, getSignal(null, config), "An item with no baseType is dropped"); + } + }); + + this.testCase({ + name: "getSignal: pageViewAs and metricsAsLogs are honoured", + test: () => { + Assert.equal(eOtlpSignal.Span, getSignal(PageViewDataType, {}), "A page view defaults to a span"); + Assert.equal(eOtlpSignal.Log, getSignal(PageViewDataType, { pageViewAs: "log" }), + "A page view can be configured as a log"); + Assert.equal(eOtlpSignal.Log, getSignal(MetricDataType, { metricsAsLogs: true }), + "A metric can be configured as a log"); + } + }); + + this.testCase({ + name: "RequestData converts to a SERVER span", + test: () => { + let item: ITelemetryItem = { + name: "Microsoft.ApplicationInsights.Request", + time: "2021-01-01T00:00:00.000Z", + iKey: "key", + baseType: RequestDataType, + baseData: { + id: "051581bf3cb55c13", + name: "GET /api/values", + url: "https://example.com/api/values", + duration: 250, + success: true, + responseCode: 200, + startTime: new Date(1609459200000) + }, + ext: { + dt: { traceId: "5b8aa5a2d2c872e8321cf37308d69df2", spanId: "051581bf3cb55c13", traceFlags: 1 } + } + }; + + let result = convertItem(item, createCtx(), "1609459200000000000"); + Assert.equal(eOtlpSignal.Span, result.signal, "The item is exported as a span"); + + let span = result.record as IOtlpSpan; + Assert.equal("5b8aa5a2d2c872e8321cf37308d69df2", span.traceId, "The trace id"); + Assert.equal("051581bf3cb55c13", span.spanId, "The span id"); + Assert.equal(eOtlpSpanKind.SERVER, span.kind, "A request is a SERVER span"); + Assert.equal("GET /api/values", span.name, "The span name"); + Assert.equal(1, span.flags, "The trace flags"); + Assert.equal("1609459200000000000", span.startTimeUnixNano, "The start time comes from baseData.startTime"); + Assert.equal("1609459200250000000", span.endTimeUnixNano, "The end time is the start plus the duration"); + Assert.equal(eOtlpStatusCode.OK, span.status.code, "success true maps to OK"); + + Assert.deepEqual({ stringValue: "GET" }, getAttr(span, "http.request.method"), "The method is re-derived"); + Assert.deepEqual({ stringValue: "https://example.com/api/values" }, getAttr(span, "url.full"), "The url"); + Assert.deepEqual({ intValue: "200" }, getAttr(span, "http.response.status_code"), "The status code"); + } + }); + + this.testCase({ + name: "RemoteDependencyData converts to a CLIENT span", + test: () => { + let item: ITelemetryItem = { + name: "Microsoft.ApplicationInsights.RemoteDependency", + time: "2021-01-01T00:00:00.000Z", + baseType: RemoteDependencyDataType, + baseData: { + id: "051581bf3cb55c13", + name: "GET /remote", + data: "https://remote.example.com/remote", + target: "remote.example.com", + type: "Http", + duration: 100, + success: false, + resultCode: 500 + } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.equal(eOtlpSpanKind.CLIENT, span.kind, "A dependency is a CLIENT span"); + Assert.equal(eOtlpStatusCode.ERROR, span.status.code, "success false maps to ERROR"); + Assert.deepEqual({ stringValue: "remote.example.com" }, getAttr(span, "server.address"), "The target"); + Assert.deepEqual({ stringValue: "https://remote.example.com/remote" }, getAttr(span, "url.full"), + "The dependency data carries the url"); + Assert.deepEqual({ intValue: "500" }, getAttr(span, "http.response.status_code"), "The result code"); + } + }); + + this.testCase({ + name: "An InProc dependency converts to an INTERNAL span", + test: () => { + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { name: "work", type: "InProc | Microsoft.EventHub", duration: 5 } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.equal(eOtlpSpanKind.INTERNAL, span.kind, "An InProc dependency is INTERNAL"); + Assert.equal(eOtlpStatusCode.UNSET, span.status.code, "An absent success value is UNSET"); + } + }); + + this.testCase({ + name: "A non numeric dependency result code is not reported as an http status", + test: () => { + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { name: "call", type: "Grpc", resultCode: "UNAVAILABLE", duration: 5 } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.equal(undefined, getAttr(span, "http.response.status_code"), "It is not an http status"); + Assert.deepEqual({ stringValue: "UNAVAILABLE" }, getAttr(span, "microsoft.result_code"), + "It is preserved under the microsoft namespace"); + } + }); + + this.testCase({ + name: "The original OpenTelemetry attributes survive the round trip through baseData.properties", + test: () => { + // `createTelemetryItemFromSpan` folds any attribute it does not map explicitly into + // baseData.properties, so this is where round trip fidelity is recovered. + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { + name: "GET /x", + duration: 1, + properties: { + "custom.attribute": "value", + "enduser.id": "user-1", + "_MS.status.description": "it broke" + } + } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.deepEqual({ stringValue: "value" }, getAttr(span, "custom.attribute"), + "A custom attribute keeps its original key"); + Assert.deepEqual({ stringValue: "user-1" }, getAttr(span, "enduser.id"), + "A semantic convention attribute keeps its original key"); + Assert.equal("it broke", span.status.message, "The status description is restored onto the status"); + } + }); + + this.testCase({ + name: "MessageData converts to a log record with the correct severity", + test: () => { + let item: ITelemetryItem = { + name: "Microsoft.ApplicationInsights.Message", + time: "2021-01-01T00:00:00.000Z", + baseType: TraceDataType, + baseData: { message: "something happened", severityLevel: eSeverityLevel.Warning } + }; + + let result = convertItem(item, createCtx(), "1609459200000000123"); + Assert.equal(eOtlpSignal.Log, result.signal, "The item is exported as a log"); + + let record = result.record as IOtlpLogRecord; + Assert.deepEqual({ stringValue: "something happened" }, record.body, "The message becomes the body"); + Assert.equal(eOtlpSeverityNumber.WARN, record.severityNumber, "The severity number"); + Assert.equal("WARN", record.severityText, "The severity text"); + Assert.equal("1609459200000000000", record.timeUnixNano, "The record time"); + Assert.equal("1609459200000000123", record.observedTimeUnixNano, "The observed time"); + } + }); + + this.testCase({ + name: "Every severity level maps onto the correct OTLP severity number", + test: () => { + let check = (level: number, expectedNumber: number, expectedText: string) => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m", severityLevel: level } + }; + + let record = convertItem(item, createCtx(), "0").record as IOtlpLogRecord; + Assert.equal(expectedNumber, record.severityNumber, "Severity number for level " + level); + Assert.equal(expectedText, record.severityText, "Severity text for level " + level); + }; + + check(eSeverityLevel.Verbose, eOtlpSeverityNumber.TRACE, "TRACE"); + check(eSeverityLevel.Information, eOtlpSeverityNumber.INFO, "INFO"); + check(eSeverityLevel.Warning, eOtlpSeverityNumber.WARN, "WARN"); + check(eSeverityLevel.Error, eOtlpSeverityNumber.ERROR, "ERROR"); + check(eSeverityLevel.Critical, eOtlpSeverityNumber.FATAL, "FATAL"); + } + }); + + this.testCase({ + name: "ExceptionData maps onto the OpenTelemetry exception attributes", + test: () => { + let item: ITelemetryItem = { + name: "Microsoft.ApplicationInsights.Exception", + baseType: ExceptionDataType, + baseData: { + exceptions: [{ + typeName: "TypeError", + message: "x is not a function", + stack: "TypeError: x is not a function\n at foo" + }] + } + }; + + let record = convertItem(item, createCtx(), "0").record as IOtlpLogRecord; + Assert.equal(eOtlpSeverityNumber.ERROR, record.severityNumber, "An exception defaults to ERROR"); + Assert.deepEqual({ stringValue: "TypeError" }, getAttr(record, "exception.type"), "The exception type"); + Assert.deepEqual({ stringValue: "x is not a function" }, getAttr(record, "exception.message"), + "The exception message"); + Assert.deepEqual({ stringValue: "TypeError: x is not a function\n at foo" }, + getAttr(record, "exception.stacktrace"), "The stack trace"); + Assert.deepEqual({ stringValue: "x is not a function" }, record.body, "The message becomes the body"); + } + }); + + this.testCase({ + name: "Additional chained exceptions are preserved rather than dropped", + test: () => { + let item: ITelemetryItem = { + name: "ex", + baseType: ExceptionDataType, + baseData: { + exceptions: [ + { typeName: "TypeError", message: "outer" }, + { typeName: "RangeError", message: "inner" } + ] + } + }; + + let record = convertItem(item, createCtx(), "0").record as IOtlpLogRecord; + let details: any = getAttr(record, "microsoft.exception.details"); + Assert.ok(!!details, "The additional exceptions are preserved"); + Assert.ok(details.stringValue.indexOf("RangeError") !== -1, "The chained exception is included"); + } + }); + + this.testCase({ + name: "EventData sets eventName and mirrors it as an attribute", + test: () => { + let item: ITelemetryItem = { + name: "Microsoft.ApplicationInsights.Event", + baseType: EventDataType, + baseData: { name: "button-clicked", properties: { page: "home" } } + }; + + let record = convertItem(item, createCtx(), "0").record as IOtlpLogRecord; + Assert.equal("button-clicked", record.eventName, "The event name is set"); + Assert.deepEqual({ stringValue: "button-clicked" }, getAttr(record, "event.name"), + "The name is mirrored for collectors that do not support eventName"); + Assert.deepEqual({ stringValue: "home" }, getAttr(record, "page"), "The custom properties are attributes"); + } + }); + + this.testCase({ + name: "The span id embedded in an Application Insights hierarchical id is used, not discarded", + test: () => { + // The dependency plugin sets id to "|." (ajaxRecord.ts). Generating a + // new span id here would break the parent/child relationships in the exported trace, + // because child telemetry references the embedded id as its parent. + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { + id: "|26b2820ab2e44659a18c79ed20332849.0680710cdc6940ce.", + name: "GET http://example.com/x", + type: "Fetch", + duration: 20, + success: true + } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.equal("0680710cdc6940ce", span.spanId, "The embedded span id is used verbatim"); + Assert.equal(undefined, getAttr(span, "microsoft.telemetry_id"), + "No id needed to be preserved because none was discarded"); + } + }); + + this.testCase({ + name: "An operation only hierarchical id does not yield a span id", + test: () => { + // "|." identifies the operation, there is no span id embedded in it, so + // truncating the trace id into a span id would fabricate a bogus identifier. + let item: ITelemetryItem = { + name: "req", + baseType: RequestDataType, + baseData: { id: "|26b2820ab2e44659a18c79ed20332849.", name: "GET /x", duration: 1, success: true } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.ok(/^[0-9a-f]{16}$/.test(span.spanId), "A span id was generated"); + Assert.notEqual("26b2820ab2e44659a1", span.spanId, "The trace id was not truncated into a span id"); + } + }); + + this.testCase({ + name: "A dependency target that is an absolute url is split into host, port and url", + test: () => { + // The auto collected dependency telemetry sets target to the absolute url + // (ajaxRecord.ts), but server.address must be the host on its own. + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { + id: "051581bf3cb55c13", + name: "GET http://localhost:8096/api/products", + target: "http://localhost:8096/api/products", + type: "Fetch", + duration: 18, + success: true + } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.deepEqual({ stringValue: "localhost" }, getAttr(span, "server.address"), + "server.address is the host only"); + Assert.deepEqual({ intValue: "8096" }, getAttr(span, "server.port"), "The port is reported separately"); + Assert.deepEqual({ stringValue: "http://localhost:8096/api/products" }, getAttr(span, "url.full"), + "The url is recovered from the target"); + } + }); + + this.testCase({ + name: "A bare host target is used as the server address unchanged", + test: () => { + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { + id: "051581bf3cb55c13", name: "call", target: "remote.example.com", + type: "Http", duration: 1, success: true + } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.deepEqual({ stringValue: "remote.example.com" }, getAttr(span, "server.address"), + "A bare host is unchanged"); + Assert.equal(undefined, getAttr(span, "server.port"), "No port is invented"); + } + }); + + this.testCase({ + name: "An explicit dependency url wins over the one recovered from the target", + test: () => { + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { + id: "051581bf3cb55c13", name: "GET /x", + data: "https://explicit.example.com/path", + target: "https://target.example.com/other", + type: "Http", duration: 1, success: true + } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.deepEqual({ stringValue: "https://explicit.example.com/path" }, getAttr(span, "url.full"), + "The explicit data url is used"); + Assert.deepEqual({ stringValue: "target.example.com" }, getAttr(span, "server.address"), + "The host still comes from the target"); + } + }); + + this.testCase({ + name: "peer.service is the host rather than the full url", + test: () => { + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { + id: "051581bf3cb55c13", name: "send", + target: "amqps://my-hub.servicebus.windows.net/queue", + type: "Queue Message", duration: 1, success: true + } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + Assert.deepEqual({ stringValue: "my-hub.servicebus.windows.net" }, getAttr(span, "peer.service"), + "peer.service is the host"); + } + }); + + this.testCase({ + name: "PageviewData converts to an INTERNAL span by default", + test: () => { + let item: ITelemetryItem = { + name: "Microsoft.ApplicationInsights.Pageview", + baseType: PageViewDataType, + baseData: { id: "051581bf3cb55c13", name: "Home", url: "https://example.com/", duration: 1200 } + }; + + let result = convertItem(item, createCtx(), "0"); + Assert.equal(eOtlpSignal.Span, result.signal, "A page view is a span by default"); + + let span = result.record as IOtlpSpan; + Assert.equal(eOtlpSpanKind.INTERNAL, span.kind, "A page view is an INTERNAL span"); + Assert.deepEqual({ stringValue: "https://example.com/" }, getAttr(span, "url.full"), "The page url"); + } + }); + + this.testCase({ + name: "A span always carries a valid traceId and spanId even when the item has neither", + test: () => { + // A page view has a page view id rather than a span id, and a page view raised before + // any operation has started has no operation id either. A span cannot be exported + // without both identifiers, so they must be generated. + let item: ITelemetryItem = { + name: "Microsoft.ApplicationInsights.Pageview", + baseType: PageViewDataType, + baseData: { name: "Home", url: "https://example.com/", duration: 10 } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + + Assert.ok(/^[0-9a-f]{32}$/.test(span.traceId), "A trace id was generated: " + span.traceId); + Assert.ok(/^[0-9a-f]{16}$/.test(span.spanId), "A span id was generated: " + span.spanId); + } + }); + + this.testCase({ + name: "A non hex telemetry id is preserved as an attribute when the span id is generated", + test: () => { + let item: ITelemetryItem = { + name: "pv", + baseType: PageViewDataType, + baseData: { id: "not-a-span-id", name: "Home", duration: 10 } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + + Assert.ok(/^[0-9a-f]{16}$/.test(span.spanId), "A valid span id was generated"); + Assert.deepEqual({ stringValue: "not-a-span-id" }, getAttr(span, "microsoft.telemetry_id"), + "The original identifier is retained so the span can still be correlated"); + } + }); + + this.testCase({ + name: "An attribute supplied through more than one part of the item is emitted only once", + test: () => { + // Application Insights copies the custom properties of an item into both + // baseData.properties and the Part C data, and a duplicated key has undefined + // behaviour in OTLP. + let item: ITelemetryItem = { + name: "pv", + baseType: PageViewDataType, + baseData: { + id: "051581bf3cb55c13", + name: "Home", + duration: 10, + properties: { "test.marker": "from-properties" } + }, + data: { "test.marker": "from-part-c" } + }; + + let span = convertItem(item, createCtx(), "0").record as IOtlpSpan; + let keys = attrKeys(span); + + let count = 0; + for (let lp = 0; lp < keys.length; lp++) { + if (keys[lp] === "test.marker") { + count++; + } + } + + Assert.equal(1, count, "The key was emitted exactly once, actual keys: " + keys.join(",")); + Assert.deepEqual({ stringValue: "from-part-c" }, getAttr(span, "test.marker"), + "The later source (Part C) won"); + } + }); + + this.testCase({ + name: "No record ever contains a duplicated attribute key", + test: () => { + let items: ITelemetryItem[] = [ + { + name: "req", baseType: RequestDataType, + baseData: { + id: "051581bf3cb55c13", name: "GET /x", duration: 1, success: true, + url: "https://example.com/x", properties: { "url.full": "https://example.com/x" } + }, + data: { "url.full": "https://example.com/x" }, + tags: { "ai.operation.name": "GET /x" } + }, + { + name: "msg", baseType: TraceDataType, + baseData: { message: "m", properties: { "microsoft.telemetry_type": "spoofed" } } + } + ]; + + for (let lp = 0; lp < items.length; lp++) { + let record: any = convertItem(items[lp], createCtx(), "0").record; + let seen: { [key: string]: number } = {}; + let attributes = record.attributes || []; + + for (let a = 0; a < attributes.length; a++) { + Assert.ok(!seen[attributes[a].key], + "The key '" + attributes[a].key + "' appears only once on item " + lp); + seen[attributes[a].key] = 1; + } + } + } + }); + + this.testCase({ + name: "PageviewData can be converted to a log record instead", + test: () => { + let item: ITelemetryItem = { + name: "pv", + baseType: PageViewDataType, + baseData: { id: "1", name: "Home", url: "https://example.com/", duration: 1200 } + }; + + let result = convertItem(item, createCtx({ pageViewAs: "log", preSerialize: false }), "0"); + Assert.equal(eOtlpSignal.Log, result.signal, "A page view can be exported as a log"); + + let record = result.record as IOtlpLogRecord; + Assert.deepEqual({ intValue: "1200" }, getAttr(record, "microsoft.duration_ms"), + "The duration is preserved as an integer attribute"); + Assert.deepEqual({ stringValue: "https://example.com/" }, getAttr(record, "url.full"), "The page url"); + } + }); + + this.testCase({ + name: "MetricData is dropped by default and converted when enabled", + test: () => { + let item: ITelemetryItem = { + name: "metric", + baseType: MetricDataType, + baseData: { metrics: [{ name: "loadTime", value: 42, count: 1, min: 42, max: 42 }] } + }; + + Assert.equal(null, convertItem(item, createCtx(), "0"), "A metric is dropped by default"); + + let result = convertItem(item, createCtx({ metricsAsLogs: true, preSerialize: false }), "0"); + Assert.ok(!!result, "A metric is converted when metricsAsLogs is enabled"); + + let record = result.record as IOtlpLogRecord; + Assert.deepEqual({ stringValue: "loadTime" }, getAttr(record, "microsoft.metric.name"), "The metric name"); + Assert.deepEqual({ intValue: "42" }, getAttr(record, "microsoft.metric.value"), "The metric value"); + } + }); + + this.testCase({ + name: "Tags promoted onto the resource are not repeated on every record", + test: () => { + let tags: any = {}; + tags[CtxTagKeys.cloudRole] = "my-service"; + tags[CtxTagKeys.operationName] = "GET /x"; + + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m" }, + tags: tags + }; + + let record = convertItem(item, createCtx(), "0").record as IOtlpLogRecord; + let keys = attrKeys(record); + + Assert.equal(-1, keys.indexOf(CtxTagKeys.cloudRole), + "The cloud role is a resource attribute so it must not be repeated per record"); + Assert.notEqual(-1, keys.indexOf(CtxTagKeys.operationName), + "A tag that is not on the resource is still emitted"); + } + }); + + this.testCase({ + name: "Part A extensions are flattened under the microsoft.ext namespace", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m" }, + ext: { + web: { browser: "Chrome", browserVer: "120" }, + dt: { traceId: "5b8aa5a2d2c872e8321cf37308d69df2" } + } + }; + + let record = convertItem(item, createCtx(), "0").record as IOtlpLogRecord; + Assert.deepEqual({ stringValue: "Chrome" }, getAttr(record, "microsoft.ext.web.browser"), + "The web extension is flattened"); + Assert.equal(undefined, getAttr(record, "microsoft.ext.dt.traceId"), + "The dt extension is consumed as trace identity rather than duplicated"); + Assert.equal("5b8aa5a2d2c872e8321cf37308d69df2", record.traceId, "The trace id is used directly"); + } + }); + + this.testCase({ + name: "piiMode drop removes a value that the Common Schema marked as PII", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { + message: "m", + properties: { + email: { value: "user@example.com", kind: 9 }, + safe: { value: "not-pii", kind: 0 } + } + } + }; + + let record = convertItem(item, createCtx({ piiMode: "drop", preSerialize: false }), "0").record as IOtlpLogRecord; + Assert.equal(undefined, getAttr(record, "email"), "A PII marked value is dropped"); + Assert.deepEqual({ stringValue: "not-pii" }, getAttr(record, "safe"), + "An unmarked value is unwrapped and kept"); + } + }); + + this.testCase({ + name: "piiMode keep emits the value together with a marker attribute", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m", properties: { email: { value: "user@example.com", kind: 9 } } } + }; + + let record = convertItem(item, createCtx({ piiMode: "keep", preSerialize: false }), "0").record as IOtlpLogRecord; + Assert.deepEqual({ stringValue: "user@example.com" }, getAttr(record, "email"), "The value is kept"); + Assert.deepEqual({ intValue: "9" }, getAttr(record, "microsoft.pii.email"), + "A marker records the value kind so that downstream can scrub it"); + } + }); + + this.testCase({ + name: "piiMode hash replaces the value with a stable hash", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m", properties: { email: { value: "user@example.com", kind: 9 } } } + }; + + let record = convertItem(item, createCtx({ piiMode: "hash", preSerialize: false }), "0").record as IOtlpLogRecord; + let value: any = getAttr(record, "email"); + Assert.ok(!!value, "The attribute is still present"); + Assert.notEqual("user@example.com", value.stringValue, "The original value is not exported"); + } + }); + + this.testCase({ + name: "preSerialize produces the serialized record rather than the object", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "hello" } + }; + + let result = convertItem(item, createCtx({ preSerialize: true }), "0"); + Assert.equal(undefined, result.record, "The object is not retained"); + Assert.ok(!!result.json, "The serialized record is produced at conversion time"); + + let parsed = JSON.parse(result.json); + Assert.deepEqual({ stringValue: "hello" }, parsed.body, "The serialized record is valid JSON"); + } + }); + + this.testCase({ + name: "The converted record holds no reference to the original telemetry item", + test: () => { + // Retaining the item would keep any DOM node or closure it references alive + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "hello" } + }; + + let result = convertItem(item, createCtx({ preSerialize: true }), "0"); + Assert.equal("string", typeof result.json, "Only a string is retained"); + Assert.equal(undefined, (result as any).item, "The item is not referenced"); + } + }); + + this.testCase({ + name: "buildPayload produces a valid OTLP trace export request", + test: () => { + let item: ITelemetryItem = { + name: "req", + baseType: RequestDataType, + baseData: { id: "051581bf3cb55c13", name: "GET /x", duration: 10, success: true } + }; + + let batcher = new OtlpBatcher(); + let key = getResourceKey(item); + let info = buildResourceInfo(item, {}, key, "1.0.0"); + batcher.add(info, convertItem(item, createCtx({ preSerialize: true }), "0")); + + let batches = batcher.takeBatches(100, 0); + Assert.equal(1, batches.length, "A single batch is produced"); + + let payload = JSON.parse(buildPayload(batches[0])); + Assert.ok(!!payload.resourceSpans, "The payload uses the resourceSpans envelope"); + Assert.equal(1, payload.resourceSpans.length, "One resource"); + Assert.equal(1, payload.resourceSpans[0].scopeSpans.length, "One scope"); + Assert.equal(1, payload.resourceSpans[0].scopeSpans[0].spans.length, "One span"); + Assert.equal("GET /x", payload.resourceSpans[0].scopeSpans[0].spans[0].name, "The span survived"); + Assert.ok(!!payload.resourceSpans[0].resource.attributes, "The resource carries attributes"); + } + }); + + this.testCase({ + name: "buildPayload produces a valid OTLP log export request", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "hello" } + }; + + let batcher = new OtlpBatcher(); + let info = buildResourceInfo(item, {}, getResourceKey(item), "1.0.0"); + batcher.add(info, convertItem(item, createCtx({ preSerialize: true }), "0")); + + let payload = JSON.parse(buildPayload(batcher.takeBatches(100, 0)[0])); + Assert.ok(!!payload.resourceLogs, "The payload uses the resourceLogs envelope"); + Assert.equal(1, payload.resourceLogs[0].scopeLogs[0].logRecords.length, "One log record"); + } + }); + + this.testCase({ + name: "Spans and logs are exported as separate batches", + test: () => { + let span: ITelemetryItem = { + name: "req", + baseType: RequestDataType, + baseData: { id: "051581bf3cb55c13", name: "GET /x", duration: 1, success: true } + }; + let log: ITelemetryItem = { name: "msg", baseType: TraceDataType, baseData: { message: "hello" } }; + + let batcher = new OtlpBatcher(); + let ctx = createCtx({ preSerialize: true }); + batcher.add(buildResourceInfo(span, {}, getResourceKey(span), "1.0.0"), convertItem(span, ctx, "0")); + batcher.add(buildResourceInfo(log, {}, getResourceKey(log), "1.0.0"), convertItem(log, ctx, "0")); + + Assert.equal(2, batcher.count(), "Both records are buffered"); + + let batches = batcher.takeBatches(100, 0); + Assert.equal(2, batches.length, "The two signals cannot share a request so they are separate batches"); + Assert.equal(0, batcher.count(), "Taking the batches empties the buffer"); + } + }); + + this.testCase({ + name: "buildResourceInfo maps the context tags onto resource attributes", + test: () => { + let tags: any = {}; + tags[CtxTagKeys.cloudRole] = "my-service"; + tags[CtxTagKeys.cloudRoleInstance] = "instance-1"; + tags[CtxTagKeys.applicationVersion] = "2.0.0"; + + let item: ITelemetryItem = { name: "msg", iKey: "the-key", tags: tags }; + let info = buildResourceInfo(item, {}, getResourceKey(item), "1.0.0"); + + Assert.deepEqual({ stringValue: "my-service" }, getAttr(info.resource, "service.name"), "service.name"); + Assert.deepEqual({ stringValue: "instance-1" }, getAttr(info.resource, "service.instance.id"), + "service.instance.id"); + Assert.deepEqual({ stringValue: "2.0.0" }, getAttr(info.resource, "service.version"), "service.version"); + Assert.deepEqual({ stringValue: "webjs" }, getAttr(info.resource, "telemetry.sdk.language"), + "telemetry.sdk.language"); + Assert.equal(undefined, getAttr(info.resource, "microsoft.instrumentation_key"), + "The instrumentation key is not included by default"); + Assert.equal(info.resourceJson, JSON.stringify(info.resource), "The resource is pre-serialized"); + } + }); + + this.testCase({ + name: "resourceAttributes override the derived values without duplicating the key", + test: () => { + let tags: any = {}; + tags[CtxTagKeys.cloudRole] = "derived"; + + let item: ITelemetryItem = { name: "msg", tags: tags }; + let info = buildResourceInfo(item, { resourceAttributes: { "service.name": "override" } }, + getResourceKey(item), "1.0.0"); + + Assert.deepEqual({ stringValue: "override" }, getAttr(info.resource, "service.name"), + "The user supplied value wins"); + + let count = 0; + for (let lp = 0; lp < info.resource.attributes.length; lp++) { + if (info.resource.attributes[lp].key === "service.name") { + count++; + } + } + Assert.equal(1, count, "A duplicate attribute key has undefined behaviour in OTLP so must not occur"); + } + }); + + this.testCase({ + name: "Items with the same context share a resource key", + test: () => { + let tags: any = {}; + tags[CtxTagKeys.cloudRole] = "my-service"; + + let first: ITelemetryItem = { name: "a", iKey: "k", tags: tags }; + let second: ITelemetryItem = { name: "b", iKey: "k", tags: tags }; + let other: ITelemetryItem = { name: "c", iKey: "different", tags: tags }; + + Assert.equal(getResourceKey(first), getResourceKey(second), "The same context produces the same key"); + Assert.notEqual(getResourceKey(first), getResourceKey(other), "A different iKey produces a different key"); + } + }); + } +} diff --git a/channels/otlp-channel-js/Tests/Unit/src/Fidelity.Tests.ts b/channels/otlp-channel-js/Tests/Unit/src/Fidelity.Tests.ts new file mode 100644 index 000000000..34d50e49c --- /dev/null +++ b/channels/otlp-channel-js/Tests/Unit/src/Fidelity.Tests.ts @@ -0,0 +1,856 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { + CtxTagKeys, EventDataType, ExceptionDataType, ITelemetryItem, MetricDataType, PageViewDataType, + PageViewPerformanceDataType, RemoteDependencyDataType, RequestDataType, TraceDataType +} from "@microsoft/applicationinsights-core-js"; +import { IOtlpChannelConfig } from "../../../src/Interfaces/IOtlpChannelConfig"; +import { IConvertCtx, convertItem } from "../../../src/convert/ItemConverter"; +import { buildResourceInfo, getResourceKey, getResourceTagKeys } from "../../../src/convert/ResourceBuilder"; + +/** + * Verifies that the FULL semantic model of both Application Insights and 1DS Common Schema telemetry + * survives conversion to OTLP. + * + * The approach is deliberately blunt: build an item with every documented field of a contract set to + * a unique sentinel value, convert it, flatten every value that appears anywhere in the resulting + * OTLP record (and its resource), and assert that every sentinel is present somewhere. + * + * A field that goes missing is data loss. A field that is intentionally not carried must be listed in + * the test's `deliberatelyDropped` set, so that every omission is a conscious, reviewed decision + * rather than an accident. + */ + +function createCtx(config?: IOtlpChannelConfig): IConvertCtx { + let theConfig: IOtlpChannelConfig = config || {}; + theConfig.piiMode = theConfig.piiMode || "keep"; + theConfig.preSerialize = false; + theConfig.metricsAsLogs = theConfig.metricsAsLogs === undefined ? true : theConfig.metricsAsLogs; + + return { + config: theConfig, + resourceTagKeys: getResourceTagKeys(), + attrOptions: { piiMode: theConfig.piiMode } + }; +} + +/** + * Recursively collects every primitive value found in the object into a string array. + */ +function collectValues(value: any, into: string[]): string[] { + if (value === null || value === undefined) { + return into; + } + + if (typeof value === "object") { + for (let key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + // Keys matter too, a value can be preserved as an attribute key + into.push("" + key); + collectValues(value[key], into); + } + } + + return into; + } + + into.push("" + value); + + return into; +} + +/** + * Converts an item and returns every value present in the resulting record plus its resource. + */ +function convertAndFlatten(item: ITelemetryItem, ctx: IConvertCtx): string[] { + let result = convertItem(item, ctx, "1700000000000000000"); + Assert.ok(!!result, "The item produced a record"); + + let values: string[] = []; + collectValues(result.record, values); + + let info = buildResourceInfo(item, ctx.config, getResourceKey(item), "1.0.0"); + collectValues(info.resource, values); + collectValues(info.scope, values); + + return values; +} + +function assertPreserved(values: string[], expected: { [field: string]: any }, + deliberatelyDropped: string[], label: string) { + + let missing: string[] = []; + + for (let field in expected) { + if (!Object.prototype.hasOwnProperty.call(expected, field)) { + continue; + } + + if (deliberatelyDropped.indexOf(field) !== -1) { + continue; + } + + let sentinel = "" + expected[field]; + if (values.indexOf(sentinel) === -1) { + missing.push(field + " (sentinel '" + sentinel + "')"); + } + } + + Assert.equal(0, missing.length, + label + " preserved every field. Missing: " + missing.join(", ")); +} + +export class FidelityTests extends AITestClass { + + public registerTests() { + + // ----------------------------------------------------------------------------------- + // Application Insights contracts + // ----------------------------------------------------------------------------------- + + this.testCase({ + name: "Fidelity: RemoteDependencyData preserves every contract field", + test: () => { + // IRemoteDependencyData: ver, name, id, resultCode, duration, success, data, target, + // type, properties, measurements + let fields = { + name: "GET /dep-name-sentinel", + id: "051581bf3cb55c13", + resultCode: "418", + data: "https://data-sentinel.example.com/path", + target: "target-sentinel.example.com", + type: "type-sentinel" + }; + + let item: ITelemetryItem = { + name: "dep", + baseType: RemoteDependencyDataType, + baseData: { + ver: 2, + name: fields.name, + id: fields.id, + resultCode: fields.resultCode, + duration: 1234, + success: false, + data: fields.data, + target: fields.target, + type: fields.type, + properties: { "prop-key-sentinel": "prop-value-sentinel" }, + measurements: { "measure-key-sentinel": 99 } + } + }; + + let values = convertAndFlatten(item, createCtx()); + + assertPreserved(values, fields, [], "RemoteDependencyData"); + Assert.notEqual(-1, values.indexOf("prop-value-sentinel"), "custom property preserved"); + Assert.notEqual(-1, values.indexOf("99"), "custom measurement preserved"); + + // The duration is carried by the span end time rather than by an attribute + let span: any = convertItem(item, createCtx(), "0").record; + let deltaNanos = Number(span.endTimeUnixNano.substring(6)) - + Number(span.startTimeUnixNano.substring(6)); + Assert.equal(1234 * 1e6, deltaNanos, "The duration is reflected in the span end time"); + } + }); + + this.testCase({ + name: "Fidelity: RequestData preserves every contract field including source", + test: () => { + // IRequestData: ver, id, name, duration, success, responseCode, source, url, + // properties, measurements + let fields = { + id: "051581bf3cb55c13", + name: "GET /request-name-sentinel", + responseCode: "503", + source: "source-sentinel", + url: "https://url-sentinel.example.com/path" + }; + + let item: ITelemetryItem = { + name: "req", + baseType: RequestDataType, + baseData: { + ver: 2, + id: fields.id, + name: fields.name, + duration: 55, + success: true, + responseCode: fields.responseCode, + source: fields.source, + url: fields.url, + properties: { "req-prop-sentinel": "req-prop-value-sentinel" }, + measurements: { "req-measure-sentinel": 7 } + } + }; + + let values = convertAndFlatten(item, createCtx()); + + assertPreserved(values, fields, [], "RequestData"); + Assert.notEqual(-1, values.indexOf("req-prop-value-sentinel"), "custom property preserved"); + } + }); + + this.testCase({ + name: "Fidelity: PageviewData preserves every contract field", + test: () => { + // IPageViewData extends IEventData: url, duration, id + name, properties, measurements + let fields = { + id: "pageview-id-sentinel", + name: "page-name-sentinel", + url: "https://page-url-sentinel.example.com/" + }; + + let item: ITelemetryItem = { + name: "pv", + baseType: PageViewDataType, + baseData: { + ver: 2, + id: fields.id, + name: fields.name, + url: fields.url, + duration: 4321, + properties: { "pv-prop-sentinel": "pv-prop-value-sentinel" }, + measurements: { "pv-measure-sentinel": 3 } + } + }; + + let values = convertAndFlatten(item, createCtx()); + + assertPreserved(values, fields, [], "PageviewData"); + Assert.notEqual(-1, values.indexOf("pv-prop-value-sentinel"), "custom property preserved"); + } + }); + + this.testCase({ + name: "Fidelity: PageviewPerformanceData preserves every contract field", + test: () => { + // IPageViewPerfData extends IPageViewData: perfTotal, networkConnect, sentRequest, + // receivedResponse, domProcessing + url, duration, id + name, properties, measurements + // + // This is the case that regressed: id, url and duration were listed as consumed but + // were only mapped for PageviewData, so they were silently lost here. + let fields = { + id: "perf-id-sentinel", + name: "perf-name-sentinel", + url: "https://perf-url-sentinel.example.com/", + perfTotal: "perf-total-sentinel", + networkConnect: "network-connect-sentinel", + sentRequest: "sent-request-sentinel", + receivedResponse: "received-response-sentinel", + domProcessing: "dom-processing-sentinel" + }; + + let item: ITelemetryItem = { + name: "pvp", + baseType: PageViewPerformanceDataType, + baseData: { + ver: 2, + id: fields.id, + name: fields.name, + url: fields.url, + duration: "00:00:01.500", + perfTotal: fields.perfTotal, + networkConnect: fields.networkConnect, + sentRequest: fields.sentRequest, + receivedResponse: fields.receivedResponse, + domProcessing: fields.domProcessing, + properties: { "perf-prop-sentinel": "perf-prop-value-sentinel" }, + measurements: { "perf-measure-sentinel": 11 } + } + }; + + let values = convertAndFlatten(item, createCtx()); + + assertPreserved(values, fields, [], "PageviewPerformanceData"); + Assert.notEqual(-1, values.indexOf("1500"), "The duration was parsed and preserved in ms"); + } + }); + + this.testCase({ + name: "Fidelity: ExceptionData preserves every IExceptionDetails and IStackFrame field", + test: () => { + // IExceptionDetails: id, outerId, typeName, message, hasFullStack, stack, parsedStack + // IStackFrame: level, method, assembly, fileName, line + let fields = { + typeName: "TypeName-sentinel", + message: "message-sentinel", + id: 42, + outerId: 24 + }; + + let item: ITelemetryItem = { + name: "ex", + baseType: ExceptionDataType, + baseData: { + ver: 2, + severityLevel: 3, + exceptions: [{ + id: fields.id, + outerId: fields.outerId, + typeName: fields.typeName, + message: fields.message, + hasFullStack: false, + parsedStack: [{ + level: 0, + method: "method-sentinel", + assembly: "assembly-sentinel", + fileName: "file-name-sentinel", + line: 1234 + }] + }], + properties: { "ex-prop-sentinel": "ex-prop-value-sentinel" } + } + }; + + let values = convertAndFlatten(item, createCtx()); + let joined = values.join("\n"); + + assertPreserved(values, fields, [], "ExceptionData"); + + // The reconstructed stack must retain the frame detail + Assert.notEqual(-1, joined.indexOf("method-sentinel"), "stack frame method preserved"); + Assert.notEqual(-1, joined.indexOf("file-name-sentinel"), "stack frame fileName preserved"); + Assert.notEqual(-1, joined.indexOf("1234"), "stack frame line preserved"); + Assert.notEqual(-1, values.indexOf("microsoft.exception.has_full_stack"), + "hasFullStack is preserved rather than dropped"); + } + }); + + this.testCase({ + name: "Fidelity: a chained exception is fully preserved", + test: () => { + let item: ITelemetryItem = { + name: "ex", + baseType: ExceptionDataType, + baseData: { + exceptions: [ + { typeName: "Outer", message: "outer-message-sentinel" }, + { typeName: "InnerType-sentinel", message: "inner-message-sentinel" } + ] + } + }; + + let joined = convertAndFlatten(item, createCtx()).join("\n"); + + Assert.notEqual(-1, joined.indexOf("InnerType-sentinel"), "the chained exception type survives"); + Assert.notEqual(-1, joined.indexOf("inner-message-sentinel"), "the chained message survives"); + } + }); + + this.testCase({ + name: "Fidelity: MetricData preserves every IDataPoint field", + test: () => { + // IDataPoint: name, kind, value, count, min, max, stdDev + let fields = { + name: "metric-name-sentinel", + kind: 1, + value: 12.5, + count: 33, + min: 3, + max: 44, + stdDev: 5 + }; + + let item: ITelemetryItem = { + name: "metric", + baseType: MetricDataType, + baseData: { + ver: 2, + metrics: [fields], + properties: { "metric-prop-sentinel": "metric-prop-value-sentinel" } + } + }; + + let values = convertAndFlatten(item, createCtx({ metricsAsLogs: true })); + + assertPreserved(values, fields, [], "MetricData"); + } + }); + + this.testCase({ + name: "Fidelity: MessageData and EventData preserve every contract field", + test: () => { + let message: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { + ver: 2, + message: "message-body-sentinel", + severityLevel: 2, + properties: { "msg-prop-sentinel": "msg-prop-value-sentinel" }, + measurements: { "msg-measure-sentinel": 5 } + } + }; + + let messageValues = convertAndFlatten(message, createCtx()); + Assert.notEqual(-1, messageValues.indexOf("message-body-sentinel"), "message preserved"); + Assert.notEqual(-1, messageValues.indexOf("WARN"), "severity preserved"); + Assert.notEqual(-1, messageValues.indexOf("msg-prop-value-sentinel"), "property preserved"); + + let event: ITelemetryItem = { + name: "evt", + baseType: EventDataType, + baseData: { + ver: 2, + name: "event-name-sentinel", + properties: { "evt-prop-sentinel": "evt-prop-value-sentinel" }, + measurements: { "evt-measure-sentinel": 6 } + } + }; + + let eventValues = convertAndFlatten(event, createCtx()); + Assert.notEqual(-1, eventValues.indexOf("event-name-sentinel"), "event name preserved"); + Assert.notEqual(-1, eventValues.indexOf("evt-prop-value-sentinel"), "property preserved"); + } + }); + + // ----------------------------------------------------------------------------------- + // Context tags -- the full ContextTagKeys surface + // ----------------------------------------------------------------------------------- + + this.testCase({ + name: "Fidelity: every context tag is either promoted to the resource or kept as an attribute", + test: () => { + let tags: any = {}; + let expected: any = {}; + + // Populate every documented context tag with a unique sentinel + let tagNames = [ + "applicationVersion", "applicationBuild", "applicationTypeId", "applicationId", + "applicationLayer", "deviceId", "deviceIp", "deviceLanguage", "deviceLocale", + "deviceModel", "deviceFriendlyName", "deviceNetwork", "deviceNetworkName", + "deviceOEMName", "deviceOS", "deviceOSVersion", "deviceRoleInstance", + "deviceRoleName", "deviceScreenResolution", "deviceType", "deviceMachineName", + "deviceVMName", "deviceBrowser", "deviceBrowserVersion", "locationIp", + "locationCountry", "locationProvince", "locationCity", "operationId", + "operationName", "operationParentId", "operationRootId", "operationSyntheticSource", + "operationCorrelationVector", "sessionId", "sessionIsFirst", "sessionIsNew", + "userAccountAcquisitionDate", "userAccountId", "userAgent", "userId", + "userStoreRegion", "userAuthUserId", "userAnonymousUserAcquisitionDate", + "userAuthenticatedUserAcquisitionDate", "cloudName", "cloudRole", "cloudRoleVer", + "cloudRoleInstance", "cloudEnvironment", "cloudLocation", "cloudDeploymentUnit", + "internalNodeName", "internalSdkVersion", "internalAgentVersion", "internalSnippet", + "internalSdkSrc" + ]; + + for (let lp = 0; lp < tagNames.length; lp++) { + let key = (CtxTagKeys as any)[tagNames[lp]]; + if (!key) { + continue; + } + + // operationId / operationParentId must stay valid ids so they can be used as the + // trace and span identity + let sentinel: string; + if (tagNames[lp] === "operationId") { + sentinel = "5b8aa5a2d2c872e8321cf37308d69df2"; + } else if (tagNames[lp] === "operationParentId") { + sentinel = "051581bf3cb55c13"; + } else { + sentinel = "tag-" + tagNames[lp] + "-sentinel"; + } + + tags[key] = sentinel; + expected[tagNames[lp]] = sentinel; + } + + let item: ITelemetryItem = { + name: "msg", + iKey: "the-key", + baseType: TraceDataType, + baseData: { message: "m" }, + tags: tags + }; + + let values = convertAndFlatten(item, createCtx()); + + assertPreserved(values, expected, [], "Context tags"); + } + }); + + this.testCase({ + name: "Fidelity: an arbitrary non-standard tag is preserved", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m" }, + tags: { "my.custom.tag": "custom-tag-value-sentinel" } as any + }; + + let values = convertAndFlatten(item, createCtx()); + Assert.notEqual(-1, values.indexOf("custom-tag-value-sentinel"), "custom tag preserved"); + Assert.notEqual(-1, values.indexOf("my.custom.tag"), "custom tag key preserved"); + } + }); + + // ----------------------------------------------------------------------------------- + // 1DS Common Schema + // ----------------------------------------------------------------------------------- + + this.testCase({ + name: "Fidelity: every Part A extension subtree is preserved", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m" }, + ext: { + user: { id: "ext-user-id-sentinel", localId: "ext-user-localid-sentinel" }, + device: { id: "ext-device-id-sentinel", deviceClass: "ext-device-class-sentinel" }, + os: { name: "ext-os-name-sentinel", ver: "ext-os-ver-sentinel" }, + app: { sesId: "ext-app-sesid-sentinel", ver: "ext-app-ver-sentinel" }, + web: { browser: "ext-web-browser-sentinel", browserVer: "ext-web-browserver-sentinel" }, + trace: { traceID: "5b8aa5a2d2c872e8321cf37308d69df2", parentID: "051581bf3cb55c13", + name: "ext-trace-name-sentinel" }, + session: { id: "ext-session-id-sentinel" }, + sdk: { ver: "ext-sdk-ver-sentinel", seq: 7 }, + loc: { tz: "ext-loc-tz-sentinel" }, + cloud: { role: "ext-cloud-role-sentinel" }, + intweb: { msfpc: "ext-intweb-msfpc-sentinel" } + } + }; + + let values = convertAndFlatten(item, createCtx()); + + let expected = { + userId: "ext-user-id-sentinel", + userLocalId: "ext-user-localid-sentinel", + deviceId: "ext-device-id-sentinel", + deviceClass: "ext-device-class-sentinel", + osName: "ext-os-name-sentinel", + osVer: "ext-os-ver-sentinel", + appSesId: "ext-app-sesid-sentinel", + appVer: "ext-app-ver-sentinel", + webBrowser: "ext-web-browser-sentinel", + webBrowserVer: "ext-web-browserver-sentinel", + traceName: "ext-trace-name-sentinel", + sessionId: "ext-session-id-sentinel", + sdkVer: "ext-sdk-ver-sentinel", + sdkSeq: 7, + locTz: "ext-loc-tz-sentinel", + cloudRole: "ext-cloud-role-sentinel", + intwebMsfpc: "ext-intweb-msfpc-sentinel" + }; + + assertPreserved(values, expected, [], "Part A extensions"); + } + }); + + this.testCase({ + name: "Fidelity: Part C data is preserved", + test: () => { + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m" }, + data: { "part-c-key-sentinel": "part-c-value-sentinel" } + }; + + let values = convertAndFlatten(item, createCtx()); + Assert.notEqual(-1, values.indexOf("part-c-value-sentinel"), "Part C value preserved"); + Assert.notEqual(-1, values.indexOf("part-c-key-sentinel"), "Part C key preserved"); + } + }); + + this.testCase({ + name: "Fidelity: every Common Schema propertyType is converted using its declared type", + test: () => { + // eEventPropertyType: String=1, Int32=2, UInt32=3, Int64=4, UInt64=5, Double=6, + // Bool=7, Guid=8, DateTime=9 + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { + message: "m", + properties: { + asString: { value: "a-string", propertyType: 1 }, + asInt32: { value: "12345", propertyType: 2 }, + asUInt32: { value: "54321", propertyType: 3 }, + asInt64: { value: "9007199254740993", propertyType: 4 }, + asUInt64: { value: "18446744073709551615", propertyType: 5 }, + asDouble: { value: "1.5", propertyType: 6 }, + asBool: { value: "true", propertyType: 7 }, + asGuid: { value: "3f2504e0-4f89-11d3-9a0c-0305e82c3301", propertyType: 8 }, + asDateTime: { value: "2021-01-01T00:00:00.000Z", propertyType: 9 } + } + } + }; + + let record: any = convertItem(item, createCtx(), "1700000000000000000").record; + let byKey: any = {}; + for (let lp = 0; lp < record.attributes.length; lp++) { + byKey[record.attributes[lp].key] = record.attributes[lp].value; + } + + Assert.deepEqual({ stringValue: "a-string" }, byKey.asString, "String stays a string"); + Assert.deepEqual({ intValue: "12345" }, byKey.asInt32, "Int32 becomes an intValue"); + Assert.deepEqual({ intValue: "54321" }, byKey.asUInt32, "UInt32 becomes an intValue"); + Assert.deepEqual({ intValue: "9007199254740993" }, byKey.asInt64, + "An Int64 beyond MAX_SAFE_INTEGER keeps every digit"); + Assert.deepEqual({ intValue: "18446744073709551615" }, byKey.asUInt64, + "A UInt64 keeps every digit"); + Assert.deepEqual({ doubleValue: 1.5 }, byKey.asDouble, "Double becomes a doubleValue"); + Assert.deepEqual({ boolValue: true }, byKey.asBool, "Bool becomes a boolValue"); + Assert.deepEqual({ stringValue: "3f2504e0-4f89-11d3-9a0c-0305e82c3301" }, byKey.asGuid, + "A guid stays a string"); + Assert.deepEqual({ stringValue: "2021-01-01T00:00:00.000Z" }, byKey.asDateTime, + "A datetime stays a string"); + } + }); + + this.testCase({ + name: "Fidelity: every Common Schema value kind is handled", + test: () => { + // eValueKind: NotSet = 0, Pii_* = 1..15, CustomerContent_GenericContent = 32 + let kinds = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 32]; + let properties: any = {}; + for (let lp = 0; lp < kinds.length; lp++) { + properties["kind" + kinds[lp]] = { value: "value-" + kinds[lp], kind: kinds[lp] }; + } + + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m", properties: properties } + }; + + // drop mode: everything marked (kind > 0) must be absent, kind 0 must be present + let dropped: any = convertItem(item, createCtx({ piiMode: "drop" }), "0").record; + let droppedKeys: string[] = []; + for (let lp = 0; lp < (dropped.attributes || []).length; lp++) { + droppedKeys.push(dropped.attributes[lp].key); + } + + Assert.notEqual(-1, droppedKeys.indexOf("kind0"), "An unmarked value is kept"); + for (let lp = 0; lp < kinds.length; lp++) { + if (kinds[lp] === 0) { + continue; + } + + Assert.equal(-1, droppedKeys.indexOf("kind" + kinds[lp]), + "Value kind " + kinds[lp] + " is dropped in drop mode"); + } + + // keep mode: every marked value present, each with its marker + let kept: any = convertItem(item, createCtx({ piiMode: "keep" }), "0").record; + let keptKeys: string[] = []; + for (let lp = 0; lp < (kept.attributes || []).length; lp++) { + keptKeys.push(kept.attributes[lp].key); + } + + for (let lp = 0; lp < kinds.length; lp++) { + // Pii_DropValue (15) is always dropped, whatever the mode, so it is asserted + // separately by its own test rather than here. + if (kinds[lp] === 15) { + Assert.equal(-1, keptKeys.indexOf("kind15"), + "Pii_DropValue is dropped even in keep mode"); + continue; + } + + Assert.notEqual(-1, keptKeys.indexOf("kind" + kinds[lp]), + "Value kind " + kinds[lp] + " is present in keep mode"); + + if (kinds[lp] > 0) { + Assert.notEqual(-1, keptKeys.indexOf("microsoft.pii.kind" + kinds[lp]), + "Value kind " + kinds[lp] + " carries a PII marker"); + } + } + } + }); + + this.testCase({ + name: "Fidelity: a PII marked value never leaks in drop or hash mode", + test: () => { + let secret = "user@secret-sentinel.example.com"; + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m", properties: { email: { value: secret, kind: 9 } } } + }; + + let droppedJson = JSON.stringify(convertItem(item, createCtx({ piiMode: "drop" }), "0").record); + Assert.equal(-1, droppedJson.indexOf(secret), "The value does not appear anywhere in drop mode"); + + let hashedJson = JSON.stringify(convertItem(item, createCtx({ piiMode: "hash" }), "0").record); + Assert.equal(-1, hashedJson.indexOf(secret), "The value does not appear anywhere in hash mode"); + } + }); + + this.testCase({ + name: "Fidelity: an unrecognised baseType is exported rather than dropped", + test: () => { + let item: ITelemetryItem = { + name: "custom", + baseType: "SomeFutureDataType", + baseData: { customField: "future-value-sentinel" } + }; + + let values = convertAndFlatten(item, createCtx()); + Assert.notEqual(-1, values.indexOf("future-value-sentinel"), + "An unknown type still carries its data"); + Assert.notEqual(-1, values.indexOf("SomeFutureDataType"), + "The original baseType is recorded"); + } + }); + + this.testCase({ + name: "Fidelity: a native Common Schema OTelSpan is exported as a SPAN, not a log", + test: () => { + // The core produces baseType "OTelSpan" (Ms.Web.Span) for a native span. Routing it to + // a log record would destroy kind, parentage, status and trace state. + let item: ITelemetryItem = { + name: "Ms.Web.Span", + baseType: "OTelSpan", + baseData: { + name: "otel-span-name-sentinel", + kind: 2, // eOTelSpanKind.CLIENT + startTime: "2021-01-01T00:00:00.000Z", + duration: 250, + success: true, + parentId: "051581bf3cb55c13", + traceState: "vendor=trace-state-sentinel", + statusMessage: "status-message-sentinel", + httpMethod: "GET", + httpUrl: "https://otel-url-sentinel.example.com:8443/path", + httpStatusCode: 201, + dbSystem: "db-system-sentinel", + dbStatement: "db-statement-sentinel", + rpcSystem: "rpc-system-sentinel" + }, + ext: { dt: { traceId: "5b8aa5a2d2c872e8321cf37308d69df2", spanId: "00f067aa0ba902b7" } } + }; + + let result = convertItem(item, createCtx(), "0"); + Assert.equal(0, result.signal, "An OTelSpan is exported as a span"); + + let span: any = result.record; + Assert.equal("otel-span-name-sentinel", span.name, "The span name"); + Assert.equal("00f067aa0ba902b7", span.spanId, "The span id"); + Assert.equal("051581bf3cb55c13", span.parentSpanId, "The parent span id"); + Assert.equal("vendor=trace-state-sentinel", span.traceState, "The trace state"); + Assert.equal("status-message-sentinel", span.status.message, "The status message"); + + // eOTelSpanKind.CLIENT is 2, but OTLP SpanKind CLIENT is 3 + Assert.equal(3, span.kind, + "The span kind is translated, not copied (the two enumerations differ by one)"); + + let values = collectValues(span, []); + let expected = { + httpUrl: "https://otel-url-sentinel.example.com:8443/path", + dbSystem: "db-system-sentinel", + dbStatement: "db-statement-sentinel", + rpcSystem: "rpc-system-sentinel" + }; + assertPreserved(values, expected, [], "OTelSpan Part B"); + } + }); + + this.testCase({ + name: "Fidelity: every SDK span kind maps to the correct OTLP span kind", + test: () => { + // eOTelSpanKind INTERNAL(0) SERVER(1) CLIENT(2) PRODUCER(3) CONSUMER(4) + // OTLP SpanKind INTERNAL(1) SERVER(2) CLIENT(3) PRODUCER(4) CONSUMER(5) + let expected = [1, 2, 3, 4, 5]; + + for (let sdkKind = 0; sdkKind <= 4; sdkKind++) { + let item: ITelemetryItem = { + name: "Ms.Web.Span", + baseType: "OTelSpan", + baseData: { name: "s", kind: sdkKind, duration: 1, success: true } + }; + + let span: any = convertItem(item, createCtx(), "0").record; + Assert.equal(expected[sdkKind], span.kind, + "SDK kind " + sdkKind + " maps to OTLP kind " + expected[sdkKind]); + } + } + }); + + this.testCase({ + name: "Fidelity: Pii_DropValue is always dropped, whatever the configured mode", + test: () => { + // eValueKind.Pii_DropValue = 15 documents itself as "Drops the value altogether, + // rather than hashing", so it must override the configured piiMode. + let secret = "drop-me-sentinel"; + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { message: "m", properties: { mustDrop: { value: secret, kind: 15 } } } + }; + + let modes: any[] = ["drop", "keep", "hash"]; + for (let lp = 0; lp < modes.length; lp++) { + let json = JSON.stringify(convertItem(item, createCtx({ piiMode: modes[lp] }), "0").record); + Assert.equal(-1, json.indexOf(secret), + "Pii_DropValue is not exported in '" + modes[lp] + "' mode"); + Assert.equal(-1, json.indexOf("mustDrop"), + "The attribute itself is absent in '" + modes[lp] + "' mode"); + } + } + }); + + this.testCase({ + name: "Fidelity: a PII value nested inside another property does not leak", + test: () => { + // A nested IEventProperty never reaches the top level resolver, so without explicit + // handling it would be serialized verbatim by the AnyValue conversion. + let secret = "nested-secret-sentinel"; + let item: ITelemetryItem = { + name: "msg", + baseType: TraceDataType, + baseData: { + message: "m", + properties: { + outer: { + safe: "safe-value", + inner: { value: secret, kind: 9 }, + deeper: { level2: { value: "deep-secret-sentinel", kind: 9 } } + } + } + } + }; + + let droppedJson = JSON.stringify(convertItem(item, createCtx({ piiMode: "drop" }), "0").record); + Assert.equal(-1, droppedJson.indexOf(secret), "A nested PII value is dropped"); + Assert.equal(-1, droppedJson.indexOf("deep-secret-sentinel"), + "A PII value nested two levels down is dropped"); + Assert.notEqual(-1, droppedJson.indexOf("safe-value"), "The unmarked sibling survives"); + + let hashedJson = JSON.stringify(convertItem(item, createCtx({ piiMode: "hash" }), "0").record); + Assert.equal(-1, hashedJson.indexOf(secret), "A nested PII value is hashed, not emitted"); + } + }); + + this.testCase({ + name: "Fidelity: the documented deliberate omissions are the only omissions", + test: () => { + // These item level members are transport / routing hints rather than telemetry, and + // are intentionally not exported. Listing them here makes the decision explicit. + let item: any = { + name: "msg", + baseType: TraceDataType, + baseData: { ver: 2, message: "m" }, + ver: "4.0", + latency: 3, + persistence: 2, + sync: 1, + timings: { processTelemetryStart: { aisku: 1 } } + }; + + let record: any = convertItem(item, createCtx(), "0").record; + let json = JSON.stringify(record); + + // Documented as deliberately dropped: they describe how the SDK should route the + // event, not what happened in the application. + Assert.equal(-1, json.indexOf("\"microsoft.latency\""), "latency is a routing hint, not exported"); + Assert.equal(-1, json.indexOf("\"microsoft.persistence\""), "persistence is a routing hint"); + Assert.equal(-1, json.indexOf("\"microsoft.sync\""), "sync is a routing hint"); + Assert.equal(-1, json.indexOf("\"microsoft.timings\""), "timings are SDK internal"); + + // But the actual telemetry is still there + Assert.notEqual(-1, json.indexOf("\"m\""), "the message itself is exported"); + } + }); + } +} diff --git a/channels/otlp-channel-js/Tests/Unit/src/GlobalTestHooks.Test.ts b/channels/otlp-channel-js/Tests/Unit/src/GlobalTestHooks.Test.ts new file mode 100644 index 000000000..b67bf2fe7 --- /dev/null +++ b/channels/otlp-channel-js/Tests/Unit/src/GlobalTestHooks.Test.ts @@ -0,0 +1,13 @@ +import { Assert } from "@microsoft/ai-test-framework"; +import { _testHookMaxUnloadHooksCb } from "@microsoft/applicationinsights-core-js"; +import { dumpObj } from "@nevware21/ts-utils"; + +export class GlobalTestHooks { + + public registerTests() { + // Set a global maximum + _testHookMaxUnloadHooksCb(20, (state: string, hooks: Array) => { + Assert.ok(false, "Max unload hooks exceeded [" + hooks.length + "] - " + state + " - " + dumpObj(hooks)); + }); + } +} diff --git a/channels/otlp-channel-js/Tests/Unit/src/TimeUtils.Tests.ts b/channels/otlp-channel-js/Tests/Unit/src/TimeUtils.Tests.ts new file mode 100644 index 000000000..3a79001c2 --- /dev/null +++ b/channels/otlp-channel-js/Tests/Unit/src/TimeUtils.Tests.ts @@ -0,0 +1,241 @@ +import { AITestClass, Assert } from "@microsoft/ai-test-framework"; +import { + addMillisToUnixNanoStr, epochMillisToUnixNanoStr, hrTimeToUnixNanoStr, parseDurationMs, toEpochMillis +} from "../../../src/convert/TimeUtils"; +import { generateSpanId, generateTraceId, normalizeSpanId, normalizeTraceId } from "../../../src/convert/IdUtils"; +import { addAttribute, createAttributeWriter, hashValue, toAnyValue } from "../../../src/convert/AttributeBuilder"; + +export class TimeUtilsTests extends AITestClass { + + public registerTests() { + + this.testCase({ + name: "hrTimeToUnixNanoStr: composes the value exactly with a zero padded nanosecond component", + test: () => { + Assert.equal("1609459200500000000", hrTimeToUnixNanoStr([1609459200, 500000000] as any), + "A half second offset"); + Assert.equal("1609459200000000001", hrTimeToUnixNanoStr([1609459200, 1] as any), + "A single nanosecond must be padded to 9 digits"); + Assert.equal("1609459200000000000", hrTimeToUnixNanoStr([1609459200, 0] as any), + "A zero nanosecond component"); + Assert.equal("0", hrTimeToUnixNanoStr(null), "A missing time"); + } + }); + + this.testCase({ + name: "hrTimeToUnixNanoStr: retains full precision beyond Number.MAX_SAFE_INTEGER", + test: () => { + // 1.7e18 nanoseconds is ~190x larger than Number.MAX_SAFE_INTEGER (9007199254740991), so + // computing this as a number would silently lose the low order digits. + let result = hrTimeToUnixNanoStr([1700000000, 123456789] as any); + Assert.equal("1700000000123456789", result, "Every digit must survive"); + Assert.equal(19, result.length, "The value is a 19 digit number"); + + // Demonstrate that the naive numeric approach really would have lost precision, which + // is the entire reason this helper exists. + let naive = "" + ((1700000000 * 1e9) + 123456789); + Assert.notEqual(result, naive, "The naive numeric computation loses precision"); + } + }); + + this.testCase({ + name: "hrTimeToUnixNanoStr: normalizes an overflowing nanosecond component", + test: () => { + Assert.equal("1609459201000000000", hrTimeToUnixNanoStr([1609459200, 1000000000] as any), + "A full second of nanoseconds rolls into the seconds component"); + Assert.equal("1609459201500000000", hrTimeToUnixNanoStr([1609459200, 1500000000] as any), + "One and a half seconds of nanoseconds"); + } + }); + + this.testCase({ + name: "epochMillisToUnixNanoStr: converts milliseconds without losing precision", + test: () => { + Assert.equal("1609459200000000000", epochMillisToUnixNanoStr(1609459200000), "A whole second"); + Assert.equal("1609459200123000000", epochMillisToUnixNanoStr(1609459200123), "With milliseconds"); + Assert.equal("0", epochMillisToUnixNanoStr(NaN), "NaN is not a time"); + Assert.equal("0", epochMillisToUnixNanoStr(Infinity), "Infinity is not a time"); + } + }); + + this.testCase({ + name: "addMillisToUnixNanoStr: adds a duration in the high resolution domain", + test: () => { + Assert.equal("1700000000123456789", addMillisToUnixNanoStr("1700000000123456789", 0), + "A zero duration returns the original value"); + Assert.equal("1700000001123456789", addMillisToUnixNanoStr("1700000000123456789", 1000), + "Adding a second"); + Assert.equal("1700000000133456789", addMillisToUnixNanoStr("1700000000123456789", 10), + "Adding 10ms must not disturb the low order digits"); + Assert.equal("1700000000124456789", addMillisToUnixNanoStr("1700000000123456789", 1), + "Adding 1ms"); + } + }); + + this.testCase({ + name: "addMillisToUnixNanoStr: rolls the nanosecond component over correctly", + test: () => { + // 999_000_000ns + 2ms == 1_001_000_000ns which must roll into the next second + Assert.equal("1700000001001000000", addMillisToUnixNanoStr("1700000000999000000", 2), + "The nanosecond overflow rolls into the seconds"); + } + }); + + this.testCase({ + name: "parseDurationMs: accepts numbers and timespan strings", + test: () => { + Assert.equal(1234, parseDurationMs(1234), "A plain number"); + Assert.equal(0, parseDurationMs(null), "A missing value"); + Assert.equal(0, parseDurationMs(undefined), "An undefined value"); + Assert.equal(1000, parseDurationMs("00:00:01"), "hh:mm:ss"); + Assert.equal(1500, parseDurationMs("00:00:01.500"), "hh:mm:ss.fff"); + Assert.equal(3661000, parseDurationMs("01:01:01"), "An hour, minute and second"); + Assert.equal(90061000, parseDurationMs("1.01:01:01"), "d.hh:mm:ss"); + Assert.equal(1123, parseDurationMs("00:00:01.1234567"), "7 digit fractional seconds truncate to ms"); + Assert.equal(42, parseDurationMs("42"), "A numeric string"); + } + }); + + this.testCase({ + name: "toEpochMillis: resolves each supported representation", + test: () => { + let date = new Date(1609459200000); + Assert.equal(1609459200000, toEpochMillis(date), "A Date instance"); + Assert.equal(1609459200000, toEpochMillis(1609459200000), "A number"); + Assert.equal(1609459200000, toEpochMillis("2021-01-01T00:00:00.000Z"), "An ISO string"); + Assert.equal(null, toEpochMillis(null), "A missing value"); + Assert.equal(null, toEpochMillis("not a date"), "An unparsable string"); + } + }); + + this.testCase({ + name: "normalizeTraceId / normalizeSpanId: normalize and reject invalid values", + test: () => { + Assert.equal("5b8aa5a2d2c872e8321cf37308d69df2", normalizeTraceId("5b8aa5a2d2c872e8321cf37308d69df2"), + "An already valid trace id"); + Assert.equal("5b8aa5a2d2c872e8321cf37308d69df2", normalizeTraceId("5B8AA5A2D2C872E8321CF37308D69DF2"), + "Uppercase is lowered"); + Assert.equal("00000000000000000000000000000001", normalizeTraceId("1"), + "A short id is left padded"); + Assert.equal(null, normalizeTraceId("00000000000000000000000000000000"), + "An all zero trace id is invalid"); + Assert.equal(null, normalizeTraceId(""), "An empty trace id"); + Assert.equal(null, normalizeTraceId(null), "A missing trace id"); + + Assert.equal("051581bf3cb55c13", normalizeSpanId("051581bf3cb55c13"), "An already valid span id"); + Assert.equal("051581bf3cb55c13", normalizeSpanId("05-15-81-bf-3c-b5-5c-13"), + "Separators are removed"); + Assert.equal(null, normalizeSpanId("0000000000000000"), "An all zero span id is invalid"); + Assert.equal(null, normalizeSpanId("not-a-span-id"), + "An arbitrary string is rejected rather than coerced into a plausible identifier"); + Assert.equal(null, normalizeTraceId("my-page-view-name"), "An arbitrary trace id is rejected"); + } + }); + + this.testCase({ + name: "toAnyValue: maps each JavaScript type onto the correct AnyValue member", + test: () => { + Assert.deepEqual({ stringValue: "hello" }, toAnyValue("hello"), "A string"); + Assert.deepEqual({ boolValue: true }, toAnyValue(true), "A boolean"); + Assert.deepEqual({ intValue: "42" }, toAnyValue(42), "A safe integer becomes a string encoded int"); + Assert.deepEqual({ intValue: "-42" }, toAnyValue(-42), "A negative integer"); + Assert.deepEqual({ doubleValue: 1.5 }, toAnyValue(1.5), "A float"); + Assert.deepEqual({ stringValue: "NaN" }, toAnyValue(NaN), "NaN is not representable in JSON"); + Assert.deepEqual({ stringValue: "Infinity" }, toAnyValue(Infinity), "Infinity is not representable in JSON"); + Assert.deepEqual({}, toAnyValue(null), "Null produces an empty AnyValue"); + Assert.deepEqual({}, toAnyValue(undefined), "Undefined produces an empty AnyValue"); + } + }); + + this.testCase({ + name: "generateTraceId / generateSpanId produce valid, non repeating identifiers", + test: () => { + let traceId = generateTraceId(); + let spanId = generateSpanId(); + + Assert.ok(/^[0-9a-f]{32}$/.test(traceId), "A generated trace id is 32 lowercase hex characters: " + traceId); + Assert.ok(/^[0-9a-f]{16}$/.test(spanId), "A generated span id is 16 lowercase hex characters: " + spanId); + Assert.notEqual("00000000000000000000000000000000", traceId, "A generated trace id is never all zeros"); + Assert.notEqual("0000000000000000", spanId, "A generated span id is never all zeros"); + + let seen: { [key: string]: number } = {}; + let collisions = 0; + for (let lp = 0; lp < 200; lp++) { + let id = generateSpanId(); + if (seen[id]) { + collisions++; + } + seen[id] = 1; + } + + Assert.equal(0, collisions, "200 generated span ids were all distinct"); + } + }); + + this.testCase({ + name: "The attribute writer replaces a repeated key rather than duplicating it", + test: () => { + // A duplicated key has undefined behaviour in OTLP, and Application Insights routinely + // supplies the same custom property through more than one part of an item. + let writer = createAttributeWriter({ piiMode: "drop" }); + + addAttribute(writer, "first", "one"); + addAttribute(writer, "shared", "original"); + addAttribute(writer, "last", "two"); + addAttribute(writer, "shared", "replacement"); + + Assert.equal(3, writer.attrs.length, "The repeated key did not add another entry"); + Assert.equal("first", writer.attrs[0].key, "The ordering is preserved"); + Assert.equal("shared", writer.attrs[1].key, "The repeated key kept its original position"); + Assert.equal("last", writer.attrs[2].key, "The trailing attribute is untouched"); + Assert.deepEqual({ stringValue: "replacement" }, writer.attrs[1].value, "The later value won"); + } + }); + + this.testCase({ + name: "toAnyValue: an integer beyond the safe range is emitted as a double", + test: () => { + // 2^53 cannot be represented exactly, so emitting it as an intValue would be a lie + let unsafe = 9007199254740993; + let result: any = toAnyValue(unsafe); + Assert.ok(result.doubleValue !== undefined || result.intValue !== undefined, "A numeric member is set"); + Assert.deepEqual({ intValue: "9007199254740991" }, toAnyValue(9007199254740991), + "The largest safe integer is still an int"); + } + }); + + this.testCase({ + name: "toAnyValue: arrays and objects", + test: () => { + Assert.deepEqual({ arrayValue: { values: [{ stringValue: "a" }, { intValue: "1" }] } }, + toAnyValue(["a", 1]), "A mixed array"); + Assert.deepEqual({ kvlistValue: { values: [{ key: "a", value: { intValue: "1" } }] } }, + toAnyValue({ a: 1 }), "A plain object"); + Assert.deepEqual({ stringValue: "2021-01-01T00:00:00.000Z" }, toAnyValue(new Date(1609459200000)), + "A Date is emitted as an ISO string"); + } + }); + + this.testCase({ + name: "toAnyValue: a cyclic object does not throw", + test: () => { + let cyclic: any = { name: "root" }; + cyclic.self = cyclic; + + let result = toAnyValue(cyclic); + Assert.ok(!!result, "A value is still produced for a cyclic object"); + } + }); + + this.testCase({ + name: "hashValue: is stable and does not return the original value", + test: () => { + let first = hashValue("user@example.com"); + let second = hashValue("user@example.com"); + Assert.equal(first, second, "The same input always produces the same hash"); + Assert.notEqual("user@example.com", first, "The original value is not returned"); + Assert.notEqual(first, hashValue("other@example.com"), "A different input produces a different hash"); + } + }); + } +} diff --git a/channels/otlp-channel-js/Tests/Unit/src/otlpchannel.tests.ts b/channels/otlp-channel-js/Tests/Unit/src/otlpchannel.tests.ts new file mode 100644 index 000000000..bb42b4d0f --- /dev/null +++ b/channels/otlp-channel-js/Tests/Unit/src/otlpchannel.tests.ts @@ -0,0 +1,15 @@ +import { GlobalTestHooks } from "./GlobalTestHooks.Test"; +import { ChannelChainTests } from "./ChannelChain.Tests"; +import { ConverterTests } from "./Converter.Tests"; +import { FidelityTests } from "./Fidelity.Tests"; +import { OtlpChannelTests } from "./Channel.Tests"; +import { TimeUtilsTests } from "./TimeUtils.Tests"; + +export function runTests() { + new GlobalTestHooks().registerTests(); + new TimeUtilsTests().registerTests(); + new ConverterTests().registerTests(); + new FidelityTests().registerTests(); + new OtlpChannelTests().registerTests(); + new ChannelChainTests().registerTests(); +} diff --git a/channels/otlp-channel-js/Tests/UnitTests.html b/channels/otlp-channel-js/Tests/UnitTests.html new file mode 100644 index 000000000..fe8c3751c --- /dev/null +++ b/channels/otlp-channel-js/Tests/UnitTests.html @@ -0,0 +1,46 @@ + + + + + + + Tests for Application Insights JavaScript API + + + + + + + + +
+
+
+ + + \ No newline at end of file diff --git a/channels/otlp-channel-js/Tests/tsconfig.json b/channels/otlp-channel-js/Tests/tsconfig.json new file mode 100644 index 000000000..7e96867a6 --- /dev/null +++ b/channels/otlp-channel-js/Tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "sourceMap": true, + "inlineSources": true, + "noImplicitAny": false, + "module": "amd", + "moduleResolution": "Node", + "target": "es5", + "alwaysStrict": true, + "declaration": true + }, + "include": [ + ], + "exclude": [ + "node_modules/" + ] +} \ No newline at end of file diff --git a/channels/otlp-channel-js/api-extractor.json b/channels/otlp-channel-js/api-extractor.json new file mode 100644 index 000000000..5402d8e10 --- /dev/null +++ b/channels/otlp-channel-js/api-extractor.json @@ -0,0 +1,361 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ + { + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + /** + * Optionally specifies another JSON config file that this file extends from. This provides a way for + * standard settings to be shared across multiple projects. + * + * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains + * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be + * resolved using NodeJS require(). + * + * SUPPORTED TOKENS: none + * DEFAULT VALUE: "" + */ + // "extends": "./shared/api-extractor-base.json" + // "extends": "my-package/include/api-extractor-base.json" + + /** + * Determines the "" token that can be used with other config file settings. The project folder + * typically contains the tsconfig.json and package.json config files, but the path is user-defined. + * + * The path is resolved relative to the folder of the config file that contains the setting. + * + * The default value for "projectFolder" is the token "", which means the folder is determined by traversing + * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder + * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error + * will be reported. + * + * SUPPORTED TOKENS: + * DEFAULT VALUE: "" + */ + "projectFolder": ".", + + /** + * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor + * analyzes the symbols exported by this module. + * + * The file extension must be ".d.ts" and not ".ts". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + */ + "mainEntryPointFilePath": "/build/types/applicationinsights-otlpchannel-js.d.ts", + + /** + * A list of NPM package names whose exports should be treated as part of this package. + * + * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1", + * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part + * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly + * imports library2. To avoid this, we can specify: + * + * "bundledPackages": [ "library2" ], + * + * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been + * local files for library1. + */ + "bundledPackages": [ + ], + + /** + * Determines how the TypeScript compiler engine will be invoked by API Extractor. + */ + "compiler": { + /** + * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * Note: This setting will be ignored if "overrideTsconfig" is used. + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/tsconfig.json" + */ + // "tsconfigFilePath": "/tsconfig.json", + + /** + * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. + * The object must conform to the TypeScript tsconfig schema: + * + * http://json.schemastore.org/tsconfig + * + * If omitted, then the tsconfig.json file will be read from the "projectFolder". + * + * DEFAULT VALUE: no overrideTsconfig section + */ + // "overrideTsconfig": { + // . . . + // } + + /** + * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended + * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when + * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses + * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck. + * + * DEFAULT VALUE: false + */ + // "skipLibCheck": true, + }, + + /** + * Configures how the API report file (*.api.md) will be generated. + */ + "apiReport": { + /** + * (REQUIRED) Whether to generate an API report. + */ + "enabled": true, + + /** + * The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce + * a full file path. + * + * The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/". + * + * SUPPORTED TOKENS: , + * DEFAULT VALUE: ".api.md" + */ + "reportFileName": ".api.md", + + /** + * Specifies the folder where the API report file is written. The file name portion is determined by + * the "reportFileName" setting. + * + * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, + * e.g. for an API review. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/etc/" + */ + "reportFolder": "/build/dts/", + + /** + * Specifies the folder where the temporary report file is written. The file name portion is determined by + * the "reportFileName" setting. + * + * After the temporary file is written to disk, it is compared with the file in the "reportFolder". + * If they are different, a production build will fail. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/temp/" + */ + // "reportTempFolder": "/temp/" + }, + + /** + * Configures how the doc model file (*.api.json) will be generated. + */ + "docModel": { + /** + * (REQUIRED) Whether to generate a doc model file. + */ + "enabled": true, + + /** + * The output path for the doc model file. The file extension should be ".api.json". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/temp/.api.json" + */ + "apiJsonFilePath": "/build/dts/.api.json" + }, + + /** + * Configures how the .d.ts rollup file will be generated. + */ + "dtsRollup": { + /** + * (REQUIRED) Whether to generate the .d.ts rollup file. + */ + "enabled": true, + + /** + * Specifies the output path for a .d.ts rollup file to be generated without any trimming. + * This file will include all declarations that are exported by the main entry point. + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/dist/.d.ts" + */ + "untrimmedFilePath": "/build/dts/.d.ts", + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release. + * This file will include only declarations that are marked as "@public" or "@beta". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + // "betaTrimmedFilePath": "/build/dts/-beta.d.ts", + + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release. + * This file will include only declarations that are marked as "@public". + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + // "publicTrimmedFilePath": "/build/dts/-public.d.ts", + + /** + * When a declaration is trimmed, by default it will be replaced by a code comment such as + * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the + * declaration completely. + * + * DEFAULT VALUE: false + */ + // "omitTrimmingComments": true + }, + + /** + * Configures how the tsdoc-metadata.json file will be generated. + */ + "tsdocMetadata": { + /** + * Whether to generate the tsdoc-metadata.json file. + * + * DEFAULT VALUE: true + */ + "enabled": false, + + /** + * Specifies where the TSDoc metadata file should be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * The default value is "", which causes the path to be automatically inferred from the "tsdocMetadata", + * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup + * falls back to "tsdoc-metadata.json" in the package folder. + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + "tsdocMetadataFilePath": "/build/dts/tsdoc-metadata.json" + }, + + /** + * Configures how API Extractor reports error and warning messages produced during analysis. + * + * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages. + */ + "messages": { + /** + * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing + * the input .d.ts files. + * + * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551" + * + * DEFAULT VALUE: A single "default" entry with logLevel=warning. + */ + "compilerMessageReporting": { + /** + * Configures the default routing for messages that don't match an explicit rule in this table. + */ + "default": { + /** + * Specifies whether the message should be written to the the tool's output log. Note that + * the "addToApiReportFile" property may supersede this option. + * + * Possible values: "error", "warning", "none" + * + * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail + * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes + * the "--local" option), the warning is displayed but the build will not fail. + * + * DEFAULT VALUE: "warning" + */ + "logLevel": "warning", + + /** + * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md), + * then the message will be written inside that file; otherwise, the message is instead logged according to + * the "logLevel" option. + * + * DEFAULT VALUE: false + */ + // "addToApiReportFile": false + }, + + // "TS2551": { + // "logLevel": "warning", + // "addToApiReportFile": true + // }, + // + // . . . + }, + + /** + * Configures handling of messages reported by API Extractor during its analysis. + * + * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag" + * + * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings + */ + "extractorMessageReporting": { + "default": { + "logLevel": "warning", + // "addToApiReportFile": false + }, + + "ae-missing-release-tag": { + "logLevel": "none" + }, + // + // . . . + }, + + /** + * Configures handling of messages reported by the TSDoc parser when analyzing code comments. + * + * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text" + * + * DEFAULT VALUE: A single "default" entry with logLevel=warning. + */ + "tsdocMessageReporting": { + "default": { + "logLevel": "warning", + // "addToApiReportFile": false + } + + // "tsdoc-link-tag-unescaped-text": { + // "logLevel": "warning", + // "addToApiReportFile": true + // }, + // + // . . . + } + } + + } + \ No newline at end of file diff --git a/channels/otlp-channel-js/package.json b/channels/otlp-channel-js/package.json new file mode 100644 index 000000000..0d7606488 --- /dev/null +++ b/channels/otlp-channel-js/package.json @@ -0,0 +1,73 @@ +{ + "name": "@microsoft/applicationinsights-otlpchannel-js", + "version": "0.1.0", + "description": "Microsoft Application Insights JavaScript SDK OTLP/JSON Channel", + "homepage": "https://github.com/microsoft/ApplicationInsights-JS#readme", + "author": "Microsoft Application Insights Team", + "main": "dist/es5/applicationinsights-otlpchannel-js.js", + "module": "dist-es5/applicationinsights-otlpchannel-js.js", + "types": "types/applicationinsights-otlpchannel-js.d.ts", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/ApplicationInsights-JS/tree/main/channels/otlp-channel-js" + }, + "scripts": { + "clean": "git clean -xdf", + "build": "npm run build:esm && npm run build:browser && npm run sri && npm run dtsgen", + "build:esm": "grunt otlpchannel", + "build:browser": "rollup -c rollup.config.js --bundleConfigAsCjs", + "rebuild": "npm run build", + "test": "grunt otlpchanneltest", + "mintest": "grunt otlpchannel-mintest", + "lint": "tslint -p tsconfig.json", + "dtsgen": "api-extractor run --local && node ../../scripts/dtsgen.js \"Microsoft Application Insights JavaScript SDK OTLP Channel\"", + "sri": "node ../../tools/subResourceIntegrity/generateIntegrityFile.js", + "ai-min": "grunt otlpchannel-min", + "ai-restore": "grunt otlpchannel-restore", + "npm-pack": "npm pack", + "npm-publish-ai": "node ../../tools/release-tools/npm_publish.js .", + "api-docs": "typedoc" + }, + "devDependencies": { + "@microsoft/ai-test-framework": "0.0.1", + "@microsoft/applicationinsights-rollup-plugin-uglify3-js": "1.0.0", + "@microsoft/applicationinsights-rollup-es5": "1.0.2", + "@microsoft/api-extractor": "^7.40.0", + "@types/sinon": "4.3.3", + "grunt": "^1.6.1", + "grunt-cli": "^1.5.0", + "@nevware21/grunt-ts-plugin": "^0.5.2", + "@nevware21/grunt-eslint-ts": "^0.5.2", + "globby": "^11.0.0", + "magic-string": "^0.25.7", + "@rollup/plugin-commonjs": "^24.0.0", + "@rollup/plugin-node-resolve": "^15.0.1", + "@rollup/plugin-replace": "^5.0.2", + "rollup": "^3.20.0", + "rollup-plugin-cleanup": "^3.2.1", + "rollup-plugin-sourcemaps": "^0.6.3", + "typescript": "^4.9.3", + "tslib": "^2.0.0", + "typedoc": "^0.26.6", + "sinon": "^7.3.1", + "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^7.14.1", + "@typescript-eslint/parser": "^7.14.1", + "eslint-plugin-security": "^1.5.0", + "grunt-contrib-connect": "^5.0.0", + "eventemitter2": "6.4.9", + "puppeteer": "^24.40.0" + }, + "peerDependencies": { + "tslib": ">= 1.0.0" + }, + "dependencies": { + "@microsoft/dynamicproto-js": "^2.0.3", + "@microsoft/applicationinsights-shims": "3.0.1", + "@microsoft/applicationinsights-core-js": "3.4.3", + "@nevware21/ts-utils": ">= 0.14.0 < 2.x", + "@nevware21/ts-async": ">= 0.5.5 < 0.6.0" + }, + "license": "MIT" +} diff --git a/channels/otlp-channel-js/rollup.config.js b/channels/otlp-channel-js/rollup.config.js new file mode 100644 index 000000000..e223c7ca2 --- /dev/null +++ b/channels/otlp-channel-js/rollup.config.js @@ -0,0 +1,38 @@ +import { createConfig } from "../../rollup.base.config"; +import { updateDistEsmFiles } from "../../tools/updateDistEsm/updateDistEsm"; + +const version = require("./package.json").version; +const browserEntryPointName = "applicationinsights-otlpchannel-js"; +const browserOutputName = "applicationinsights-otlpchannel-js"; +const entryPointName = "applicationinsights-otlpchannel-js"; +const outputName = "applicationinsights-otlpchannel-js"; +const banner = [ + "/*!", + ` * Application Insights JavaScript SDK - OTLP Channel, ${version}`, + " * Copyright (c) Microsoft and contributors. All rights reserved.", + " */" +].join("\n"); + +const replaceValues = { + "// Copyright (c) Microsoft Corporation. All rights reserved.": "", + "// Licensed under the MIT License.": "" +}; + + +updateDistEsmFiles(replaceValues, banner, true, true, "dist-es5"); + +export default createConfig(banner, + { + namespace: "Microsoft.ApplicationInsights", + version: version, + node: { + entryPoint: entryPointName, + outputName: outputName + }, + browser: { + entryPoint: browserEntryPointName, + outputName: browserOutputName + } + }, + [ "applicationinsights-otlpchannel-js" ] +); diff --git a/channels/otlp-channel-js/src/Enums.ts b/channels/otlp-channel-js/src/Enums.ts new file mode 100644 index 000000000..a3714407b --- /dev/null +++ b/channels/otlp-channel-js/src/Enums.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createEnumStyle } from "@microsoft/applicationinsights-core-js"; + +/** + * Identifies which OTLP signal a telemetry item is exported as. + */ +export const enum eOtlpSignal { + /** + * The item is exported as an OTLP Span to the `/v1/traces` endpoint. + */ + Span = 0, + + /** + * The item is exported as an OTLP LogRecord to the `/v1/logs` endpoint. + */ + Log = 1 +} + +export const OtlpSignal = (/* @__PURE__ */ createEnumStyle({ + Span: eOtlpSignal.Span, + Log: eOtlpSignal.Log +})); +export type OtlpSignal = number | eOtlpSignal; + +/** + * The OTLP `SpanKind` enumeration values. + */ +export const enum eOtlpSpanKind { + UNSPECIFIED = 0, + INTERNAL = 1, + SERVER = 2, + CLIENT = 3, + PRODUCER = 4, + CONSUMER = 5 +} + +export const OtlpSpanKind = (/* @__PURE__ */ createEnumStyle({ + UNSPECIFIED: eOtlpSpanKind.UNSPECIFIED, + INTERNAL: eOtlpSpanKind.INTERNAL, + SERVER: eOtlpSpanKind.SERVER, + CLIENT: eOtlpSpanKind.CLIENT, + PRODUCER: eOtlpSpanKind.PRODUCER, + CONSUMER: eOtlpSpanKind.CONSUMER +})); +export type OtlpSpanKind = number | eOtlpSpanKind; + +/** + * The OTLP `StatusCode` enumeration values. + */ +export const enum eOtlpStatusCode { + UNSET = 0, + OK = 1, + ERROR = 2 +} + +export const OtlpStatusCode = (/* @__PURE__ */ createEnumStyle({ + UNSET: eOtlpStatusCode.UNSET, + OK: eOtlpStatusCode.OK, + ERROR: eOtlpStatusCode.ERROR +})); +export type OtlpStatusCode = number | eOtlpStatusCode; + +/** + * The subset of the OTLP `SeverityNumber` enumeration that the Application Insights severity levels + * map onto. + */ +export const enum eOtlpSeverityNumber { + UNSPECIFIED = 0, + TRACE = 1, + DEBUG = 5, + INFO = 9, + WARN = 13, + ERROR = 17, + FATAL = 21 +} + +export const OtlpSeverityNumber = (/* @__PURE__ */ createEnumStyle({ + UNSPECIFIED: eOtlpSeverityNumber.UNSPECIFIED, + TRACE: eOtlpSeverityNumber.TRACE, + DEBUG: eOtlpSeverityNumber.DEBUG, + INFO: eOtlpSeverityNumber.INFO, + WARN: eOtlpSeverityNumber.WARN, + ERROR: eOtlpSeverityNumber.ERROR, + FATAL: eOtlpSeverityNumber.FATAL +})); +export type OtlpSeverityNumber = number | eOtlpSeverityNumber; diff --git a/channels/otlp-channel-js/src/Interfaces/IOtlpChannelConfig.ts b/channels/otlp-channel-js/src/Interfaces/IOtlpChannelConfig.ts new file mode 100644 index 000000000..969855adb --- /dev/null +++ b/channels/otlp-channel-js/src/Interfaces/IOtlpChannelConfig.ts @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IXHROverride, TransportType } from "@microsoft/applicationinsights-core-js"; + +/** + * How a `PageviewData` telemetry item should be represented in OTLP. + */ +export type OtlpPageViewMode = "span" | "log"; + +/** + * How values that the Common Schema marks as PII or customer content should be handled. OTLP has no + * equivalent of the Common Schema `ext.metadata` markers so the value must either be removed, + * emitted with a marker attribute, or replaced. + */ +export type OtlpPiiMode = "drop" | "keep" | "hash"; + +/** + * Configuration for the OTLP channel, supplied via the `extensionConfig` using the `OtlpChannel` + * identifier. + * + * @example + * ```typescript + * const appInsights = new ApplicationInsights({ + * config: { + * instrumentationKey: "YOUR_KEY", + * extensionConfig: { + * ["OtlpChannel"]: { + * endpointUrl: "https://collector.example.com" + * } + * } + * } + * }); + * ``` + */ +export interface IOtlpChannelConfig { + /** + * The base OTLP/HTTP endpoint. The signal specific path (`/v1/traces` or `/v1/logs`) is appended + * to this value, so `https://collector.example.com` results in + * `https://collector.example.com/v1/traces`. + */ + endpointUrl?: string; + + /** + * The complete url used to export spans. When supplied this takes precedence over + * {@link IOtlpChannelConfig.endpointUrl} and no path is appended. + */ + tracesEndpointUrl?: string; + + /** + * The complete url used to export log records. When supplied this takes precedence over + * {@link IOtlpChannelConfig.endpointUrl} and no path is appended. + */ + logsEndpointUrl?: string; + + /** + * Additional headers to include on every export request, typically used to supply authentication. + * @remarks + * Custom headers require the collector to support the resulting CORS preflight and are not + * supported when the payload is sent using `navigator.sendBeacon`. + */ + headers?: { [key: string]: string }; + + /** + * Additional resource attributes, these are merged over the values derived from the telemetry + * context so they may be used to override the derived `service.name` and friends. + */ + resourceAttributes?: { [key: string]: string | number | boolean }; + + /** + * The instrumentation scope name reported for all exported records. + * Defaults to `@microsoft/applicationinsights-web`. + */ + scopeName?: string; + + /** + * The instrumentation scope version reported for all exported records. + * Defaults to the version of this package. + */ + scopeVersion?: string; + + /** + * Convert and serialize each record as it is received rather than when the batch is sent. + * @remarks + * This is the default (and recommended) mode, it moves all of the conversion cost onto the + * (already asynchronous) `processTelemetry` path so that sending a batch -- including during page + * unload -- performs no conversion work at all. Set to `false` to retain the converted objects + * and serialize the whole payload at send time. + * Defaults to `true`. + */ + preSerialize?: boolean; + + /** + * Whether a page view is exported as a span or a log record. + * Defaults to `span`. + */ + pageViewAs?: OtlpPageViewMode; + + /** + * Export `MetricData` items as log records. The OTLP metrics signal is not yet supported, so when + * this is `false` metric items are ignored. + * Defaults to `false`. + */ + metricsAsLogs?: boolean; + + /** + * How values marked as PII or customer content should be handled. + * Defaults to `drop`. + */ + piiMode?: OtlpPiiMode; + + /** + * The maximum number of bytes of serialized records that will be sent in a single request. When + * the buffered payload reaches this size a send is triggered. + * Defaults to `65536`. + */ + maxBatchSizeInBytes?: number; + + /** + * The maximum number of records that will be sent in a single request. When the buffer reaches + * this many records a send is triggered. + * Defaults to `512`. + */ + maxRecordsPerBatch?: number; + + /** + * The maximum number of milliseconds to buffer records before sending them. + * Defaults to `15000`. + */ + maxBatchInterval?: number; + + /** + * The maximum number of records to hold in memory. Once reached the oldest records are dropped + * and an `eventsDiscarded` notification is raised. + * Defaults to `10000`. + */ + eventsLimitInMem?: number; + + /** + * The ordered transports to use when sending asynchronously. + */ + transports?: TransportType | TransportType[]; + + /** + * The ordered transports to use when sending during page unload. + */ + unloadTransports?: TransportType | TransportType[]; + + /** + * A user supplied transport used in preference to the built in transports. + */ + httpXHROverride?: IXHROverride; + + /** + * The `credentials` value used for `fetch` based requests. + */ + fetchCredentials?: RequestCredentials; + + /** + * Disable the use of synchronous `XMLHttpRequest` during unload. + */ + disableXhrSync?: boolean; + + /** + * Disable the use of `fetch` with `keepalive` during unload. + */ + disableFetchKeepAlive?: boolean; + + /** + * The timeout (in milliseconds) applied to `XMLHttpRequest` based requests. + */ + xhrTimeout?: number; + + /** + * The maximum number of times a failed batch is retried before it is discarded. + * Defaults to `6`. + */ + maxRetryAttempts?: number; + + /** + * The maximum number of times a failed batch is retried while the page is unloading. + * Defaults to `2`. + */ + maxUnloadRetryAttempts?: number; + + /** + * Stop the channel from converting and sending any telemetry, items are still passed along the + * plugin chain. + * Defaults to `false`. + */ + disableTelemetry?: boolean; + + /** + * Stop passing telemetry items to the next plugin in the chain once this channel has converted + * them. Only enable this when the OTLP channel is the only consumer of the telemetry. + * Defaults to `false`. + */ + consumeEvents?: boolean; + + /** + * Include the instrumentation key as the `microsoft.instrumentation_key` resource attribute. + * Defaults to `false`. + */ + includeIKeyInResource?: boolean; +} diff --git a/channels/otlp-channel-js/src/Interfaces/IOtlpTypes.ts b/channels/otlp-channel-js/src/Interfaces/IOtlpTypes.ts new file mode 100644 index 000000000..49f570201 --- /dev/null +++ b/channels/otlp-channel-js/src/Interfaces/IOtlpTypes.ts @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * OTLP/JSON wire types. + * + * These interfaces model the JSON encoding of the OpenTelemetry Protocol (OTLP) as described by + * https://github.com/open-telemetry/opentelemetry-proto/blob/main/docs/specification.md + * + * Notable JSON encoding rules that these types encode: + * - `int64` / `uint64` fields are represented as decimal **strings** (not numbers) so that values + * larger than `Number.MAX_SAFE_INTEGER` survive the round trip. + * - `bytes` fields (`traceId` / `spanId`) are represented as **lowercase hex** strings. This is a + * deliberate deviation from the standard proto3 JSON mapping which would use base64. + * - Enumerations are represented as their numeric values. + */ + +/** + * A single attribute value. This models the OTLP `AnyValue` message which is a `oneof`, so exactly + * one of the members should be populated. + */ +export interface IOtlpAnyValue { + stringValue?: string; + boolValue?: boolean; + /** + * A 64bit integer encoded as a decimal string. + */ + intValue?: string; + doubleValue?: number; + arrayValue?: IOtlpArrayValue; + kvlistValue?: IOtlpKeyValueList; + /** + * A byte array encoded as a base64 string. + */ + bytesValue?: string; +} + +export interface IOtlpArrayValue { + values: IOtlpAnyValue[]; +} + +export interface IOtlpKeyValueList { + values: IOtlpKeyValue[]; +} + +/** + * A single key / value attribute pair. + */ +export interface IOtlpKeyValue { + key: string; + value: IOtlpAnyValue; +} + +/** + * Identifies the entity producing the telemetry. + */ +export interface IOtlpResource { + attributes?: IOtlpKeyValue[]; + droppedAttributesCount?: number; +} + +/** + * Identifies the instrumentation library / scope that produced the telemetry. + */ +export interface IOtlpInstrumentationScope { + name?: string; + version?: string; + attributes?: IOtlpKeyValue[]; + droppedAttributesCount?: number; +} + +/** + * The status of a span. + */ +export interface IOtlpStatus { + message?: string; + /** + * 0 = UNSET, 1 = OK, 2 = ERROR + */ + code?: number; +} + +/** + * A timestamped event recorded on a span. + */ +export interface IOtlpSpanEvent { + timeUnixNano?: string; + name?: string; + attributes?: IOtlpKeyValue[]; + droppedAttributesCount?: number; +} + +/** + * A pointer from the current span to another span. + */ +export interface IOtlpSpanLink { + traceId?: string; + spanId?: string; + traceState?: string; + attributes?: IOtlpKeyValue[]; + droppedAttributesCount?: number; + flags?: number; +} + +/** + * A single OTLP span. + */ +export interface IOtlpSpan { + /** + * 32 lowercase hex characters (16 bytes). + */ + traceId?: string; + /** + * 16 lowercase hex characters (8 bytes). + */ + spanId?: string; + traceState?: string; + parentSpanId?: string; + flags?: number; + name?: string; + /** + * See {@link eOtlpSpanKind} + */ + kind?: number; + /** + * Nanoseconds since the unix epoch encoded as a decimal string. + */ + startTimeUnixNano?: string; + /** + * Nanoseconds since the unix epoch encoded as a decimal string. + */ + endTimeUnixNano?: string; + attributes?: IOtlpKeyValue[]; + droppedAttributesCount?: number; + events?: IOtlpSpanEvent[]; + droppedEventsCount?: number; + links?: IOtlpSpanLink[]; + droppedLinksCount?: number; + status?: IOtlpStatus; +} + +/** + * A single OTLP log record. + */ +export interface IOtlpLogRecord { + /** + * Nanoseconds since the unix epoch encoded as a decimal string. + */ + timeUnixNano?: string; + /** + * Nanoseconds since the unix epoch encoded as a decimal string, identifying when the record was + * observed by the collection system. + */ + observedTimeUnixNano?: string; + /** + * See {@link eOtlpSeverityNumber} + */ + severityNumber?: number; + severityText?: string; + body?: IOtlpAnyValue; + attributes?: IOtlpKeyValue[]; + droppedAttributesCount?: number; + flags?: number; + traceId?: string; + spanId?: string; + /** + * The name that identifies the class / type of this event. Added in a later revision of the OTLP + * specification, so a mirrored attribute is also emitted for collectors that do not support it. + */ + eventName?: string; +} + +export interface IOtlpScopeSpans { + scope?: IOtlpInstrumentationScope; + spans?: IOtlpSpan[]; + schemaUrl?: string; +} + +export interface IOtlpResourceSpans { + resource?: IOtlpResource; + scopeSpans?: IOtlpScopeSpans[]; + schemaUrl?: string; +} + +export interface IOtlpScopeLogs { + scope?: IOtlpInstrumentationScope; + logRecords?: IOtlpLogRecord[]; + schemaUrl?: string; +} + +export interface IOtlpResourceLogs { + resource?: IOtlpResource; + scopeLogs?: IOtlpScopeLogs[]; + schemaUrl?: string; +} + +/** + * The body of a POST to the OTLP `/v1/traces` endpoint. + */ +export interface IOtlpTraceExportRequest { + resourceSpans: IOtlpResourceSpans[]; +} + +/** + * The body of a POST to the OTLP `/v1/logs` endpoint. + */ +export interface IOtlpLogExportRequest { + resourceLogs: IOtlpResourceLogs[]; +} + +/** + * Reported by a collector when it accepted a request but rejected some of the records it contained. + * Requests that report a partial success must NOT be retried. + */ +export interface IOtlpPartialSuccess { + rejectedSpans?: number | string; + rejectedLogRecords?: number | string; + rejectedDataPoints?: number | string; + errorMessage?: string; +} + +export interface IOtlpExportResponse { + partialSuccess?: IOtlpPartialSuccess; +} diff --git a/channels/otlp-channel-js/src/InternalConstants.ts b/channels/otlp-channel-js/src/InternalConstants.ts new file mode 100644 index 000000000..5269d4f01 --- /dev/null +++ b/channels/otlp-channel-js/src/InternalConstants.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export const STR_EMPTY = ""; +export const STR_OTLP_CHANNEL = "OtlpChannel"; + +/** + * The prefix applied to attributes that carry Application Insights specific information which has no + * OpenTelemetry semantic convention equivalent. Keeping these in a dedicated namespace avoids + * colliding with (and being mistaken for) a real semantic convention. + */ +export const MS_PREFIX = "microsoft."; +export const MS_EXT_PREFIX = "microsoft.ext."; + +/** + * The Part A extension names that are consumed directly rather than being copied into attributes. + */ +export const EXT_DT = "dt"; +export const EXT_TRACE = "trace"; +export const EXT_METADATA = "metadata"; + +/** + * OpenTelemetry semantic convention attribute keys used by the converter. + */ +export const ATTR_HTTP_REQUEST_METHOD = "http.request.method"; +export const ATTR_HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"; +export const ATTR_URL_FULL = "url.full"; +export const ATTR_SERVER_ADDRESS = "server.address"; +export const ATTR_SERVER_PORT = "server.port"; +export const ATTR_PEER_SERVICE = "peer.service"; +export const ATTR_DB_SYSTEM = "db.system"; +export const ATTR_DB_STATEMENT = "db.statement"; +export const ATTR_RPC_SYSTEM = "rpc.system"; +export const ATTR_EXCEPTION_TYPE = "exception.type"; +export const ATTR_EXCEPTION_MESSAGE = "exception.message"; +export const ATTR_EXCEPTION_STACKTRACE = "exception.stacktrace"; +export const ATTR_URL_PATH = "url.path"; +export const ATTR_EVENT_NAME = "event.name"; + +/** + * OpenTelemetry semantic convention resource attribute keys used by the resource builder. + */ +export const ATTR_SERVICE_NAME = "service.name"; +export const ATTR_SERVICE_VERSION = "service.version"; +export const ATTR_SERVICE_INSTANCE_ID = "service.instance.id"; +export const ATTR_TELEMETRY_SDK_NAME = "telemetry.sdk.name"; +export const ATTR_TELEMETRY_SDK_LANGUAGE = "telemetry.sdk.language"; +export const ATTR_TELEMETRY_SDK_VERSION = "telemetry.sdk.version"; +export const ATTR_DEVICE_ID = "device.id"; +export const ATTR_DEVICE_MODEL_NAME = "device.model.name"; +export const ATTR_OS_TYPE = "os.type"; +export const ATTR_OS_VERSION = "os.version"; +export const ATTR_BROWSER_LANGUAGE = "browser.language"; +export const ATTR_USER_ID = "user.id"; + +/** + * The default instrumentation scope reported for exported records. + */ +export const DEFAULT_SCOPE_NAME = "@microsoft/applicationinsights-web"; + +/** + * The signal specific paths appended to the configured base endpoint. + */ +export const PATH_TRACES = "/v1/traces"; +export const PATH_LOGS = "/v1/logs"; diff --git a/channels/otlp-channel-js/src/OtlpBatcher.ts b/channels/otlp-channel-js/src/OtlpBatcher.ts new file mode 100644 index 000000000..adc0c6b2c --- /dev/null +++ b/channels/otlp-channel-js/src/OtlpBatcher.ts @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { arrForEach, isNullOrUndefined } from "@nevware21/ts-utils"; +import { eOtlpSignal } from "./Enums"; +import { safeStringify } from "./convert/AttributeBuilder"; +import { IOtlpRecord } from "./convert/ItemConverter"; +import { IOtlpResourceInfo } from "./convert/ResourceBuilder"; + +/** + * A batch of records that are ready to be sent, all of which share a resource and a signal. + */ +export interface IOtlpBatch { + signal: eOtlpSignal; + + resourceInfo: IOtlpResourceInfo; + + /** + * The serialized records making up this batch. + */ + fragments: string[]; + + /** + * The total number of bytes of the serialized records, maintained incrementally. + */ + bytes: number; + + /** + * The number of times sending this batch has been attempted. + */ + attempts: number; +} + +/** + * The buffered records for a single resource. + */ +interface IOtlpBucket { + resourceInfo: IOtlpResourceInfo; + spans: string[]; + logs: string[]; + spanBytes: number; + logBytes: number; +} + +/** + * Accumulates converted records, grouped by resource and signal, so that building an export payload + * requires nothing more than joining strings. + * + * @remarks + * The batcher never sees an `ITelemetryItem`; records are already converted (and normally already + * serialized) by the time they arrive here. Byte totals are maintained incrementally as records are + * added so that deciding whether a batch is full is a constant time comparison rather than a walk + * of the buffer. + */ +export class OtlpBatcher { + + private _buckets: { [key: string]: IOtlpBucket }; + private _order: string[]; + private _count: number; + private _bytes: number; + + constructor() { + this._buckets = {}; + this._order = []; + this._count = 0; + this._bytes = 0; + } + + /** + * Adds a converted record to the buffer. + * @param resourceInfo - The resource that the record belongs to. + * @param record - The converted record. + * @returns The number of bytes that the record added to the buffer. + */ + public add(resourceInfo: IOtlpResourceInfo, record: IOtlpRecord): number { + let key = resourceInfo.key; + let bucket = this._buckets[key]; + if (!bucket) { + bucket = this._buckets[key] = { + resourceInfo: resourceInfo, + spans: [], + logs: [], + spanBytes: 0, + logBytes: 0 + }; + + this._order.push(key); + } + + // When the channel is not pre-serializing the record is serialized here instead, which still + // keeps the cost off the send path. + let json = isNullOrUndefined(record.json) ? safeStringify(record.record) : record.json; + let bytes = json.length; + + if (record.signal === eOtlpSignal.Span) { + bucket.spans.push(json); + bucket.spanBytes += bytes; + } else { + bucket.logs.push(json); + bucket.logBytes += bytes; + } + + this._count++; + this._bytes += bytes; + + return bytes; + } + + /** + * The number of buffered records. + */ + public count(): number { + return this._count; + } + + /** + * The total number of bytes of the buffered records. + */ + public size(): number { + return this._bytes; + } + + /** + * Removes and returns every buffered record as a set of batches, one per resource and signal. + * @param maxRecords - The maximum number of records to include in a single batch, `0` for no limit. + * @param maxBytes - The maximum number of bytes to include in a single batch, `0` for no limit. + * @returns The batches that were removed from the buffer. + */ + public takeBatches(maxRecords?: number, maxBytes?: number): IOtlpBatch[] { + let batches: IOtlpBatch[] = []; + let buckets = this._buckets; + let order = this._order; + + arrForEach(order, (key) => { + let bucket = buckets[key]; + if (!bucket) { + return; + } + + _split(batches, bucket.resourceInfo, eOtlpSignal.Span, bucket.spans, maxRecords, maxBytes); + _split(batches, bucket.resourceInfo, eOtlpSignal.Log, bucket.logs, maxRecords, maxBytes); + }); + + this._buckets = {}; + this._order = []; + this._count = 0; + this._bytes = 0; + + return batches; + } + + /** + * Returns a previously taken batch to the buffer so that it can be retried. + * @remarks + * The batch is placed at the head of its bucket so that the oldest records are still sent first. + * @param batch - The batch to return. + */ + public requeue(batch: IOtlpBatch): void { + if (!batch || !batch.fragments.length) { + return; + } + + let key = batch.resourceInfo.key; + let bucket = this._buckets[key]; + if (!bucket) { + bucket = this._buckets[key] = { + resourceInfo: batch.resourceInfo, + spans: [], + logs: [], + spanBytes: 0, + logBytes: 0 + }; + + this._order.push(key); + } + + let target = batch.signal === eOtlpSignal.Span ? bucket.spans : bucket.logs; + // unshift the whole batch back to the front, preserving the original ordering + for (let lp = batch.fragments.length - 1; lp >= 0; lp--) { + target.unshift(batch.fragments[lp]); + } + + if (batch.signal === eOtlpSignal.Span) { + bucket.spanBytes += batch.bytes; + } else { + bucket.logBytes += batch.bytes; + } + + this._count += batch.fragments.length; + this._bytes += batch.bytes; + } + + /** + * Drops the oldest buffered records. + * @param dropCount - The number of records to drop. + * @returns The number of records that were actually dropped. + */ + public dropOldest(dropCount: number): number { + let dropped = 0; + let buckets = this._buckets; + let order = this._order; + + for (let idx = 0; idx < order.length && dropped < dropCount; idx++) { + let bucket = buckets[order[idx]]; + if (!bucket) { + continue; + } + + dropped += this._dropFrom(bucket, true, dropCount - dropped); + if (dropped < dropCount) { + dropped += this._dropFrom(bucket, false, dropCount - dropped); + } + } + + return dropped; + } + + private _dropFrom(bucket: IOtlpBucket, isSpan: boolean, dropCount: number): number { + let target = isSpan ? bucket.spans : bucket.logs; + let dropped = 0; + + while (dropped < dropCount && target.length) { + let removed = target.shift(); + let bytes = removed.length; + if (isSpan) { + bucket.spanBytes -= bytes; + } else { + bucket.logBytes -= bytes; + } + + this._count--; + this._bytes -= bytes; + dropped++; + } + + return dropped; + } +} + +function _split(batches: IOtlpBatch[], resourceInfo: IOtlpResourceInfo, signal: eOtlpSignal, fragments: string[], + maxRecords: number, maxBytes: number): void { + if (!fragments.length) { + return; + } + + let current: string[] = []; + let currentBytes = 0; + + arrForEach(fragments, (fragment) => { + let bytes = fragment.length; + let wouldExceed = (maxRecords > 0 && current.length >= maxRecords) || + (maxBytes > 0 && current.length > 0 && (currentBytes + bytes) > maxBytes); + + if (wouldExceed) { + batches.push({ signal: signal, resourceInfo: resourceInfo, fragments: current, bytes: currentBytes, attempts: 0 }); + current = []; + currentBytes = 0; + } + + current.push(fragment); + currentBytes += bytes; + }); + + if (current.length) { + batches.push({ signal: signal, resourceInfo: resourceInfo, fragments: current, bytes: currentBytes, attempts: 0 }); + } +} + +/** + * Builds the OTLP export payload for a batch. + * + * @remarks + * Every part of the payload other than the records themselves was serialized when the resource was + * created, so this is a string concatenation and nothing more. + * + * @param batch - The batch to serialize. + * @returns The complete JSON body to POST to the collector. + */ +export function buildPayload(batch: IOtlpBatch): string { + let info = batch.resourceInfo; + let isSpan = batch.signal === eOtlpSignal.Span; + let resourceKey = isSpan ? "resourceSpans" : "resourceLogs"; + let scopeKey = isSpan ? "scopeSpans" : "scopeLogs"; + let recordKey = isSpan ? "spans" : "logRecords"; + + return "{\"" + resourceKey + "\":[{\"resource\":" + info.resourceJson + + ",\"" + scopeKey + "\":[{\"scope\":" + info.scopeJson + + ",\"" + recordKey + "\":[" + batch.fragments.join(",") + "]}]}]}"; +} diff --git a/channels/otlp-channel-js/src/OtlpChannel.ts b/channels/otlp-channel-js/src/OtlpChannel.ts new file mode 100644 index 000000000..ecbb8ac3a --- /dev/null +++ b/channels/otlp-channel-js/src/OtlpChannel.ts @@ -0,0 +1,524 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import dynamicProto from "@microsoft/dynamicproto-js"; +import { + BaseTelemetryPlugin, IAppInsightsCore, IChannelControls, IConfigDefaults, IConfiguration, IInternalOfflineSupport, IPayloadData, IPlugin, + IProcessTelemetryContext, IProcessTelemetryUnloadContext, ITelemetryItem, ITelemetryUnloadState, SendRequestReason, _eInternalMessageId, + _throwInternal, addPageHideEventListener, addPageShowEventListener, addPageUnloadEventListener, arrForEach, + createProcessTelemetryContext, createUniqueNamespace, eEventsDiscardedReason, eLoggingSeverity, hrTime, isGreaterThanZero, + mergeEvtNamespace, onConfigChange, removePageHideEventListener, removePageShowEventListener, removePageUnloadEventListener +} from "@microsoft/applicationinsights-core-js"; +import { IPromise, createPromise } from "@nevware21/ts-async"; +import { ITimerHandler, isNumber, objDeepFreeze, scheduleTimeout } from "@nevware21/ts-utils"; +import { eOtlpSignal } from "./Enums"; +import { IOtlpChannelConfig } from "./Interfaces/IOtlpChannelConfig"; +import { STR_OTLP_CHANNEL } from "./InternalConstants"; +import { IOtlpBatch, OtlpBatcher, buildPayload } from "./OtlpBatcher"; +import { IOtlpSendResult, OtlpHttpSender, getEndpointUrl } from "./OtlpHttpSender"; +import { IAttrOptions } from "./convert/AttributeBuilder"; +import { IConvertCtx, IKeyMap, IOtlpRecord, convertItem, getSignal } from "./convert/ItemConverter"; +import { IOtlpResourceInfo, buildResourceInfo, getResourceKey, getResourceTagKeys } from "./convert/ResourceBuilder"; +import { hrTimeToUnixNanoStr } from "./convert/TimeUtils"; + +const DEFAULT_MAX_BATCH_BYTES = 65536; +const DEFAULT_MAX_RECORDS = 512; +const DEFAULT_BATCH_INTERVAL = 15000; +const DEFAULT_EVENTS_LIMIT = 10000; +const DEFAULT_MAX_RETRIES = 6; +const DEFAULT_MAX_UNLOAD_RETRIES = 2; +const EVENTS_DISCARDED = "eventsDiscarded"; + +/** + * The number of records that are dropped at a time once the in memory limit is reached, dropping a + * block rather than a single record avoids repeating the drop on every subsequent item. + */ +const DROP_BLOCK = 20; + +let undefValue: undefined = undefined; + +/** + * The default configuration. Every value must be present so that the dynamic configuration system + * makes all of them individually watchable. + */ +const defaultOtlpChannelConfig: IConfigDefaults = objDeepFreeze({ + endpointUrl: undefValue, + tracesEndpointUrl: undefValue, + logsEndpointUrl: undefValue, + headers: undefValue, + resourceAttributes: undefValue, + scopeName: undefValue, + scopeVersion: undefValue, + preSerialize: true, + pageViewAs: "span", + metricsAsLogs: false, + piiMode: "drop", + maxBatchSizeInBytes: { isVal: isGreaterThanZero, v: DEFAULT_MAX_BATCH_BYTES }, + maxRecordsPerBatch: { isVal: isGreaterThanZero, v: DEFAULT_MAX_RECORDS }, + maxBatchInterval: { isVal: isGreaterThanZero, v: DEFAULT_BATCH_INTERVAL }, + eventsLimitInMem: { isVal: isGreaterThanZero, v: DEFAULT_EVENTS_LIMIT }, + transports: undefValue, + unloadTransports: undefValue, + httpXHROverride: undefValue, + fetchCredentials: undefValue, + disableXhrSync: false, + disableFetchKeepAlive: false, + xhrTimeout: undefValue, + maxRetryAttempts: { isVal: isNumber, v: DEFAULT_MAX_RETRIES }, + maxUnloadRetryAttempts: { isVal: isNumber, v: DEFAULT_MAX_UNLOAD_RETRIES }, + disableTelemetry: false, + consumeEvents: false, + includeIKeyInResource: false +}); + +/** + * A channel that converts telemetry into OTLP/JSON and exports it to an OTLP/HTTP endpoint. + * + * @remarks + * The channel sits at the end of the plugin chain and converts each telemetry item into its final + * OTLP representation as the item is received, rather than when a batch is sent. Batches are + * accumulated as already serialized records grouped by resource and signal, so sending a batch is + * only a string join and an HTTP POST. This keeps the cost of exporting off the (time critical) send + * path, which matters most during page unload. + * + * Being a channel it must be supplied using the `channels` configuration rather than `extensions`. + * + * @example + * ```typescript + * const otlpChannel = new OtlpChannel(); + * const appInsights = new ApplicationInsights({ + * config: { + * instrumentationKey: "YOUR_KEY", + * channels: [[ otlpChannel ]], + * extensionConfig: { + * ["OtlpChannel"]: { endpointUrl: "https://collector.example.com" } + * } + * } + * }); + * ``` + * @group Classes + * @group Entrypoint + */ +export class OtlpChannel extends BaseTelemetryPlugin implements IChannelControls { + + public identifier = STR_OTLP_CHANNEL; + + /** + * The priority of this channel, this is above the priority of the other channels so that when it + * shares a queue it is always the last plugin to receive an item. + */ + public priority = 1021; + + public version = "#version#"; + + constructor() { + super(); + + let _config: IOtlpChannelConfig; + let _batcher: OtlpBatcher; + let _sender: OtlpHttpSender; + let _convertCtx: IConvertCtx; + let _resourceTagKeys: IKeyMap; + let _resourceCache: { [key: string]: IOtlpResourceInfo }; + let _paused: boolean; + let _sendTimer: ITimerHandler; + let _retryTimer: ITimerHandler; + let _evtNamespace: string | string[]; + let _isPageUnloading: boolean; + let _inFlight: number; + let _pendingFlushCallbacks: Array<(flushComplete?: boolean) => void>; + + dynamicProto(OtlpChannel, this, (_self, _base) => { + + _initDefaults(); + + _self.initialize = (coreConfig: IConfiguration, core: IAppInsightsCore, extensions: IPlugin[]) => { + _base.initialize(coreConfig, core, extensions); + + _evtNamespace = mergeEvtNamespace(createUniqueNamespace("OtlpChannel"), core.evtNamespace && core.evtNamespace()); + _sender = new OtlpHttpSender(_self.diagLog()); + + _self._addHook(onConfigChange(coreConfig, () => { + let ctx = createProcessTelemetryContext(null, coreConfig, core); + _config = ctx.getExtCfg(_self.identifier, defaultOtlpChannelConfig); + + // The resource, scope and conversion context are all memoized for performance, so + // they must be rebuilt whenever the configuration that feeds them changes. + _resourceCache = {}; + _resourceTagKeys = getResourceTagKeys(); + _convertCtx = { + config: _config, + resourceTagKeys: _resourceTagKeys, + attrOptions: { piiMode: _config.piiMode } as IAttrOptions + }; + + _sender.setConfig(_config); + })); + + _addUnloadListeners(); + }; + + _self.processTelemetry = (item: ITelemetryItem, itemCtx?: IProcessTelemetryContext) => { + itemCtx = _self._getTelCtx(itemCtx); + + try { + if (!_config.disableTelemetry && item) { + _addItem(item); + } + } catch (e) { + _throwInternal(itemCtx.diagLog(), eLoggingSeverity.WARNING, _eInternalMessageId.TelemetryEnvelopeInvalid, + "Failed to convert the telemetry item to OTLP", { exception: e + "" }); + } + + if (!_config.consumeEvents) { + _self.processNext(item, itemCtx); + } + }; + + _self.pause = () => { + _clearSendTimer(); + _paused = true; + }; + + _self.resume = () => { + if (_paused) { + _paused = false; + _checkLimits(); + } + }; + + _self.flush = (isAsync: boolean = true, callBack?: (flushComplete?: boolean) => void, + sendReason?: SendRequestReason): boolean | void | IPromise => { + + if (_paused) { + callBack && callBack(false); + return false; + } + + _clearSendTimer(); + + if (!isAsync) { + _sendBatches(false, sendReason || SendRequestReason.ManualFlush); + callBack && callBack(true); + return true; + } + + if (callBack) { + _pendingFlushCallbacks.push(callBack); + _sendBatches(true, sendReason || SendRequestReason.ManualFlush); + _checkFlushComplete(); + return true; + } + + return createPromise((resolve) => { + _pendingFlushCallbacks.push(() => { + resolve(true); + }); + + _sendBatches(true, sendReason || SendRequestReason.ManualFlush); + _checkFlushComplete(); + }); + }; + + _self.onunloadFlush = () => { + _isPageUnloading = true; + _clearSendTimer(); + _sendBatches(false, SendRequestReason.Unload); + }; + + _self.getOfflineSupport = (): IInternalOfflineSupport => { + return { + getUrl: () => { + return getEndpointUrl(_config, eOtlpSignal.Span); + }, + createPayload: (data: string | Uint8Array): IPayloadData => { + return { + urlString: getEndpointUrl(_config, eOtlpSignal.Span), + data: data, + headers: { "Content-Type": "application/json" } + }; + }, + serialize: (input: ITelemetryItem): string => { + // The records are already serialized during conversion, so this is only a + // conversion of a single item rather than a second serialization layer. + let record = convertItem(input, _convertCtx, _observedNow()); + return record ? (record.json || JSON.stringify(record.record)) : null; + }, + batch: (arr: string[]): string => { + return "[" + (arr || []).join(",") + "]"; + }, + shouldProcess: (evt: ITelemetryItem): boolean => { + return !_config.disableTelemetry && !!evt && getSignal(evt.baseType, _config) !== null; + } + }; + }; + + _self._doTeardown = (unloadCtx?: IProcessTelemetryUnloadContext, unloadState?: ITelemetryUnloadState) => { + // Make a best effort attempt to export anything still buffered before we go away + _sendBatches(false, SendRequestReason.SdkUnload); + + _removeUnloadListeners(); + _clearSendTimer(); + + if (_retryTimer) { + _retryTimer.cancel(); + _retryTimer = null; + } + + _sender && _sender.teardown(); + _initDefaults(); + }; + + function _observedNow(): string { + return hrTimeToUnixNanoStr(hrTime()); + } + + function _getResourceInfo(item: ITelemetryItem): IOtlpResourceInfo { + let key = getResourceKey(item); + let info = _resourceCache[key]; + if (!info) { + // Building a resource walks the context tags and serializes the result, in a + // browser this normally happens exactly once for the lifetime of the page. + info = _resourceCache[key] = buildResourceInfo(item, _config, key, _self.version); + } + + return info; + } + + function _addItem(item: ITelemetryItem): void { + let record: IOtlpRecord = convertItem(item, _convertCtx, _observedNow()); + if (!record) { + return; + } + + _batcher.add(_getResourceInfo(item), record); + _checkLimits(); + } + + function _checkLimits(): void { + let limit = _config.eventsLimitInMem; + if (_batcher.count() > limit) { + let dropped = _batcher.dropOldest(DROP_BLOCK); + if (dropped) { + _notifyDiscarded(dropped, eEventsDiscardedReason.QueueFull); + } + } + + if (_paused) { + return; + } + + if (_batcher.count() >= _config.maxRecordsPerBatch || _batcher.size() >= _config.maxBatchSizeInBytes) { + _sendBatches(true, SendRequestReason.MaxQueuedEvents); + } else if (_batcher.count() > 0) { + _scheduleSend(); + } + } + + function _scheduleSend(): void { + if (_sendTimer || _paused) { + return; + } + + _sendTimer = scheduleTimeout(() => { + _sendTimer = null; + _sendBatches(true, SendRequestReason.NormalSchedule); + }, _config.maxBatchInterval); + } + + function _clearSendTimer(): void { + if (_sendTimer) { + _sendTimer.cancel(); + _sendTimer = null; + } + } + + function _sendBatches(isAsync: boolean, sendReason: SendRequestReason): void { + if (_config.disableTelemetry || !_batcher.count()) { + _checkFlushComplete(); + return; + } + + let batches = _batcher.takeBatches(_config.maxRecordsPerBatch, _config.maxBatchSizeInBytes); + arrForEach(batches, (batch) => { + _sendBatch(batch, isAsync, sendReason); + }); + } + + function _sendBatch(batch: IOtlpBatch, isAsync: boolean, sendReason: SendRequestReason): void { + _inFlight++; + + let started = _sender.send(batch, isAsync, (result: IOtlpSendResult) => { + _inFlight--; + _onSendComplete(batch, result); + _checkFlushComplete(); + }, sendReason); + + if (!started) { + _inFlight--; + // Without a usable transport or endpoint the records cannot be exported, drop them + // rather than letting the buffer grow without bound. + _notifyDiscarded(batch.fragments.length, eEventsDiscardedReason.NonRetryableStatus); + _checkFlushComplete(); + } + } + + function _onSendComplete(batch: IOtlpBatch, result: IOtlpSendResult): void { + if (result.success) { + return; + } + + let maxAttempts = _isPageUnloading ? _config.maxUnloadRetryAttempts : _config.maxRetryAttempts; + if (!result.retry || _isPageUnloading || batch.attempts >= maxAttempts) { + _notifyDiscarded(batch.fragments.length, eEventsDiscardedReason.NonRetryableStatus); + _throwInternal(_self.diagLog(), eLoggingSeverity.WARNING, _eInternalMessageId.TransmissionFailed, + "Failed to export " + batch.fragments.length + " OTLP record(s), status: " + result.status); + return; + } + + _batcher.requeue(batch); + _scheduleRetry(result.retryAfterMs); + } + + function _scheduleRetry(delayMs: number): void { + if (_retryTimer || _paused) { + return; + } + + _retryTimer = scheduleTimeout(() => { + _retryTimer = null; + _sendBatches(true, SendRequestReason.Retry); + }, delayMs || _config.maxBatchInterval); + } + + function _checkFlushComplete(): void { + if (_inFlight > 0 || !_pendingFlushCallbacks.length) { + return; + } + + let callbacks = _pendingFlushCallbacks; + _pendingFlushCallbacks = []; + arrForEach(callbacks, (callback) => { + try { + callback(true); + } catch (e) { + // A failing callback must not stop the remaining callbacks from running + } + }); + } + + function _notifyDiscarded(count: number, reason: eEventsDiscardedReason): void { + let core = _self.core; + let manager = core && core.getNotifyMgr && core.getNotifyMgr(); + if (manager && manager[EVENTS_DISCARDED]) { + // The records have already been converted so the original items are no longer + // available, report the count using an empty placeholder set. + let items: ITelemetryItem[] = []; + for (let lp = 0; lp < count; lp++) { + items.push({ name: STR_OTLP_CHANNEL } as ITelemetryItem); + } + + manager[EVENTS_DISCARDED](items, reason); + } + } + + function _addUnloadListeners(): void { + addPageUnloadEventListener(_onPageUnload, null, _evtNamespace); + addPageHideEventListener(_onPageUnload, null, _evtNamespace); + addPageShowEventListener(_onPageShow, null, _evtNamespace); + } + + function _removeUnloadListeners(): void { + removePageUnloadEventListener(null, _evtNamespace); + removePageHideEventListener(null, _evtNamespace); + removePageShowEventListener(null, _evtNamespace); + } + + function _onPageUnload(): void { + if (!_config || _config.disableTelemetry) { + return; + } + + _isPageUnloading = true; + _self.onunloadFlush(); + } + + function _onPageShow(): void { + // The page has been restored from the back / forward cache so it is alive again + _isPageUnloading = false; + } + + function _initDefaults(): void { + _config = null; + _batcher = new OtlpBatcher(); + _sender = null; + _convertCtx = null; + _resourceTagKeys = {}; + _resourceCache = {}; + _paused = false; + _sendTimer = null; + _retryTimer = null; + _evtNamespace = null; + _isPageUnloading = false; + _inFlight = 0; + _pendingFlushCallbacks = []; + } + }); + } + + /** + * Pause the exporting of telemetry, items continue to be converted and buffered until the + * configured in memory limit is reached at which point the oldest records are dropped. + */ + public pause(): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + /** + * Resume the exporting of telemetry. + */ + public resume(): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + /** + * Export any buffered telemetry immediately. + * @param isAsync - Send the data asynchronously when `true` (the default). + * @param callBack - Notified once the export has completed. + * @param sendReason - The reason the flush was requested. + * @returns `true` when a supplied callback will be called, otherwise an + * [IPromise](https://nevware21.github.io/ts-async/typedoc/interfaces/IPromise.html) that resolves + * once the export is complete. + */ + public flush(isAsync: boolean = true, callBack?: (flushComplete?: boolean) => void, + sendReason?: SendRequestReason): boolean | void | IPromise { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + /** + * Export any buffered telemetry synchronously, called while the page is unloading. + */ + public onunloadFlush(): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + /** + * Returns the support required by the offline channel to persist and later replay OTLP payloads. + * @returns The offline support implementation. + */ + public getOfflineSupport(): IInternalOfflineSupport { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + return null; + } + + public initialize(config: IConfiguration, core: IAppInsightsCore, extensions: IPlugin[]): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } + + public processTelemetry(item: ITelemetryItem, itemCtx?: IProcessTelemetryContext): void { + // @DynamicProtoStub -- DO NOT add any code as this will be removed during packaging + } +} + +export { buildPayload }; diff --git a/channels/otlp-channel-js/src/OtlpHttpSender.ts b/channels/otlp-channel-js/src/OtlpHttpSender.ts new file mode 100644 index 000000000..77b6d9da3 --- /dev/null +++ b/channels/otlp-channel-js/src/OtlpHttpSender.ts @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + IDiagnosticLogger, IPayloadData, IXHROverride, OnCompleteCallback, SendRequestReason, SenderPostManager, TransportType, + _ISendPostMgrConfig, _eInternalMessageId, _throwInternal, eLoggingSeverity, prependTransports +} from "@microsoft/applicationinsights-core-js"; +import { isNumber, isString, mathMax, mathMin, objForEachKey, strTrim } from "@nevware21/ts-utils"; +import { eOtlpSignal } from "./Enums"; +import { IOtlpChannelConfig } from "./Interfaces/IOtlpChannelConfig"; +import { PATH_LOGS, PATH_TRACES } from "./InternalConstants"; +import { IOtlpBatch, buildPayload } from "./OtlpBatcher"; + +/** + * The status codes that indicate the request may succeed if it is tried again. Everything else is + * treated as a permanent failure and the batch is dropped. + */ +const RETRYABLE_STATUS: { [status: number]: number } = { + 401: 1, 403: 1, 408: 1, 429: 1, 500: 1, 502: 1, 503: 1, 504: 1 +}; + +const BASE_RETRY_MS = 1000; +const MAX_RETRY_MS = 60000; + +/** + * Describes the outcome of attempting to send a batch. + */ +export interface IOtlpSendResult { + /** + * `true` when the collector accepted the batch. + */ + success: boolean; + + /** + * `true` when the batch should be retried after {@link IOtlpSendResult.retryAfterMs}. + */ + retry: boolean; + + /** + * The number of milliseconds to wait before the batch is retried. + */ + retryAfterMs?: number; + + /** + * The number of records the collector explicitly rejected. A rejected record must not be retried. + */ + rejected?: number; + + /** + * The message reported by the collector alongside a partial success. + */ + message?: string; + + /** + * The HTTP status code, `0` when the request did not complete. + */ + status?: number; +} + +/** + * Resolves the endpoint that a batch for the supplied signal should be posted to. + * @param config - The channel configuration. + * @param signal - The signal being exported. + * @returns The complete url, or an empty string when no endpoint has been configured. + */ +export function getEndpointUrl(config: IOtlpChannelConfig, signal: eOtlpSignal): string { + let isSpan = signal === eOtlpSignal.Span; + let explicitUrl = isSpan ? config.tracesEndpointUrl : config.logsEndpointUrl; + if (explicitUrl) { + return explicitUrl; + } + + let baseUrl = config.endpointUrl; + if (!baseUrl) { + return ""; + } + + // Trim any trailing separator so that the signal path is not doubled up + baseUrl = strTrim(baseUrl); + while (baseUrl.length && baseUrl.charAt(baseUrl.length - 1) === "/") { + baseUrl = baseUrl.substring(0, baseUrl.length - 1); + } + + return baseUrl + (isSpan ? PATH_TRACES : PATH_LOGS); +} + +/** + * Parses the body of a `200` response looking for the OTLP partial success information. + * @remarks + * A partial success reports records that the collector has permanently rejected, so those records + * must NOT be retried. + * @param response - The response body. + * @returns The number of rejected records and the reported message. + */ +export function parsePartialSuccess(response: string): { rejected: number, message: string } { + let result = { rejected: 0, message: null as string }; + if (!response || !isString(response)) { + return result; + } + + try { + let parsed = JSON.parse(response); + let partial = parsed && parsed.partialSuccess; + if (!partial) { + return result; + } + + let rejected = partial.rejectedSpans || partial.rejectedLogRecords || partial.rejectedDataPoints || 0; + result.rejected = +rejected || 0; + result.message = partial.errorMessage || null; + } catch (e) { + // A non JSON body is not an error, the request itself still succeeded + } + + return result; +} + +/** + * Calculates the delay before a failed batch is retried, using an exponential backoff with jitter. + * @param attempts - The number of attempts that have already been made. + * @param retryAfterHeader - The value of any `Retry-After` response header. + * @returns The number of milliseconds to wait. + */ +export function getRetryDelay(attempts: number, retryAfterHeader?: string): number { + if (retryAfterHeader) { + // Retry-After is either a number of seconds or an HTTP date + let seconds = +retryAfterHeader; + if (!isNaN(seconds) && seconds > 0) { + return mathMin(seconds * 1000, MAX_RETRY_MS); + } + + let retryDate = Date.parse(retryAfterHeader); + if (!isNaN(retryDate)) { + let delta = retryDate - (new Date()).getTime(); + if (delta > 0) { + return mathMin(delta, MAX_RETRY_MS); + } + } + } + + let backoff = BASE_RETRY_MS * Math.pow(2, mathMax(0, attempts - 1)); + // Add up to 25% jitter so that a fleet of clients does not retry in lock step + let jitter = backoff * 0.25 * Math.random(); + + return mathMin(backoff + jitter, MAX_RETRY_MS); +} + +/** + * Sends OTLP payloads to the configured collector. + */ +export class OtlpHttpSender { + + private _postMgr: SenderPostManager; + private _asyncSender: IXHROverride; + private _syncSender: IXHROverride; + private _config: IOtlpChannelConfig; + private _logger: IDiagnosticLogger; + + constructor(logger: IDiagnosticLogger) { + this._logger = logger; + this._postMgr = null; + this._asyncSender = null; + this._syncSender = null; + this._config = null; + } + + /** + * Applies (or re-applies) the channel configuration, re-resolving the transports to use. + * @param config - The channel configuration. + */ + public setConfig(config: IOtlpChannelConfig): void { + this._config = config; + + let postConfig: _ISendPostMgrConfig = { + enableSendPromise: false, + isOneDs: false, + disableCredentials: false, + disableXhr: false, + disableBeacon: false, + disableBeaconSync: false, + disableFetchKeepAlive: !!config.disableFetchKeepAlive, + fetchCredentials: config.fetchCredentials + }; + + if (!this._postMgr) { + this._postMgr = new SenderPostManager(); + this._postMgr.initialize(postConfig, this._logger); + } else { + this._postMgr.SetConfig(postConfig); + } + + // An OTLP payload is JSON with (potentially) custom headers, which `sendBeacon` cannot carry, + // so it is only used as a last resort during unload. + let asyncTransports = prependTransports([TransportType.Fetch, TransportType.Xhr], config.transports); + this._asyncSender = this._postMgr.getSenderInst(asyncTransports, false); + + let syncTransports = prependTransports([TransportType.Fetch, TransportType.Xhr, TransportType.Beacon], + config.unloadTransports); + this._syncSender = this._postMgr.getSenderInst(syncTransports, true); + + let custom = config.httpXHROverride; + if (custom && custom.sendPOST) { + this._asyncSender = custom; + this._syncSender = custom; + } + + if (!this._asyncSender) { + this._asyncSender = this._postMgr.getFallbackInst(); + } + + if (!this._syncSender) { + this._syncSender = this._asyncSender; + } + } + + /** + * Builds the payload for the supplied batch. + * @param batch - The batch to build the payload for. + * @param sendReason - The reason the payload is being sent. + * @returns The payload data, or `null` when no endpoint has been configured. + */ + public createPayload(batch: IOtlpBatch, sendReason?: SendRequestReason): IPayloadData { + let config = this._config; + let url = getEndpointUrl(config, batch.signal); + if (!url) { + return null; + } + + let headers: { [key: string]: string } = { "Content-Type": "application/json" }; + if (config.headers) { + objForEachKey(config.headers, (key, value) => { + headers[key] = value; + }); + } + + let payload: IPayloadData = { + urlString: url, + data: buildPayload(batch), + headers: headers, + disableXhrSync: !!config.disableXhrSync, + disableFetchKeepAlive: !!config.disableFetchKeepAlive, + sendReason: sendReason + }; + + if (isNumber(config.xhrTimeout)) { + payload.timeout = config.xhrTimeout; + } + + return payload; + } + + /** + * Sends a batch to the collector. + * @param batch - The batch to send. + * @param isAsync - `false` to send synchronously, used during page unload. + * @param onComplete - Invoked with the outcome once the request completes. + * @param sendReason - The reason the batch is being sent. + * @returns `true` when the request was started. + */ + public send(batch: IOtlpBatch, isAsync: boolean, onComplete: (result: IOtlpSendResult) => void, + sendReason?: SendRequestReason): boolean { + let sender = isAsync ? this._asyncSender : this._syncSender; + if (!sender || !sender.sendPOST) { + return false; + } + + let payload = this.createPayload(batch, sendReason); + if (!payload) { + _throwInternal(this._logger, eLoggingSeverity.WARNING, _eInternalMessageId.InvalidBackendResponse, + "No OTLP endpoint configured, telemetry cannot be exported"); + return false; + } + + batch.attempts++; + + let completeCallback: OnCompleteCallback = (status, headers, response) => { + onComplete(this._getResult(batch, status, headers, response)); + }; + + try { + sender.sendPOST(payload, completeCallback, !isAsync); + } catch (e) { + onComplete({ success: false, retry: true, retryAfterMs: getRetryDelay(batch.attempts), status: 0 }); + } + + return true; + } + + /** + * Releases any resources held by the sender. + */ + public teardown(): void { + this._asyncSender = null; + this._syncSender = null; + this._postMgr = null; + } + + private _getResult(batch: IOtlpBatch, status: number, headers: { [name: string]: string }, + response: string): IOtlpSendResult { + let config = this._config; + + if (status >= 200 && status < 300) { + let partial = parsePartialSuccess(response); + if (partial.rejected) { + _throwInternal(this._logger, eLoggingSeverity.WARNING, _eInternalMessageId.InvalidBackendResponse, + "The OTLP collector rejected " + partial.rejected + " record(s)" + + (partial.message ? ": " + partial.message : "")); + } + + return { + success: true, + retry: false, + rejected: partial.rejected, + message: partial.message, + status: status + }; + } + + // A status of 0 indicates the request never completed (offline, DNS failure, CORS), which is + // worth retrying. + let canRetry = status === 0 || !!RETRYABLE_STATUS[status]; + let maxAttempts = config.maxRetryAttempts; + if (batch.attempts >= maxAttempts) { + canRetry = false; + } + + return { + success: false, + retry: canRetry, + retryAfterMs: canRetry ? getRetryDelay(batch.attempts, headers ? headers["Retry-After"] : null) : 0, + status: status + }; + } +} diff --git a/channels/otlp-channel-js/src/applicationinsights-otlpchannel-js.ts b/channels/otlp-channel-js/src/applicationinsights-otlpchannel-js.ts new file mode 100644 index 000000000..70a4b09bf --- /dev/null +++ b/channels/otlp-channel-js/src/applicationinsights-otlpchannel-js.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export { OtlpChannel } from "./OtlpChannel"; +export { IOtlpChannelConfig, OtlpPageViewMode, OtlpPiiMode } from "./Interfaces/IOtlpChannelConfig"; +export { + IOtlpAnyValue, IOtlpArrayValue, IOtlpExportResponse, IOtlpInstrumentationScope, IOtlpKeyValue, IOtlpKeyValueList, + IOtlpLogExportRequest, IOtlpLogRecord, IOtlpPartialSuccess, IOtlpResource, IOtlpResourceLogs, IOtlpResourceSpans, + IOtlpScopeLogs, IOtlpScopeSpans, IOtlpSpan, IOtlpSpanEvent, IOtlpSpanLink, IOtlpStatus, IOtlpTraceExportRequest +} from "./Interfaces/IOtlpTypes"; +export { + OtlpSeverityNumber, OtlpSignal, OtlpSpanKind, OtlpStatusCode, eOtlpSeverityNumber, eOtlpSignal, eOtlpSpanKind, eOtlpStatusCode +} from "./Enums"; +export { IOtlpBatch, OtlpBatcher, buildPayload } from "./OtlpBatcher"; +export { IOtlpSendResult, OtlpHttpSender, getEndpointUrl, getRetryDelay, parsePartialSuccess } from "./OtlpHttpSender"; +export { IConvertCtx, IKeyMap, IOtlpRecord, convertItem, getSignal } from "./convert/ItemConverter"; +export { IOtlpResourceInfo, IResourceTagMap, buildResourceInfo, getResourceKey, getResourceTagKeys } from "./convert/ResourceBuilder"; +export { + IAttrOptions, IAttributeWriter, addAttribute, addAttributes, createAttributeWriter, hashValue, safeStringify, toAnyValue +} from "./convert/AttributeBuilder"; +export { + extractSpanId, generateSpanId, generateTraceId, normalizeSpanId, normalizeTraceId, parseTarget +} from "./convert/IdUtils"; +export { + addMillisToUnixNanoStr, epochMillisToUnixNanoStr, hrTimeToUnixNanoStr, parseDurationMs, toEpochMillis +} from "./convert/TimeUtils"; diff --git a/channels/otlp-channel-js/src/convert/AttributeBuilder.ts b/channels/otlp-channel-js/src/convert/AttributeBuilder.ts new file mode 100644 index 000000000..6b7366b1f --- /dev/null +++ b/channels/otlp-channel-js/src/convert/AttributeBuilder.ts @@ -0,0 +1,456 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { arrForEach, isArray, isBoolean, isNullOrUndefined, isNumber, isString, mathFloor, objForEachKey } from "@nevware21/ts-utils"; +import { OtlpPiiMode } from "../Interfaces/IOtlpChannelConfig"; +import { IOtlpAnyValue, IOtlpKeyValue } from "../Interfaces/IOtlpTypes"; +import { MS_PREFIX } from "../InternalConstants"; + +/** + * The largest integer that can be represented exactly by a JavaScript number. + * `Number.MAX_SAFE_INTEGER` is ES2015 so the value is inlined for ES5 environments. + */ +const MAX_SAFE_INT = 9007199254740991; + +/** + * Options that control how values are converted into attributes. + */ +export interface IAttrOptions { + /** + * How values that the Common Schema marks as PII or customer content are handled. + */ + piiMode: OtlpPiiMode; +} + +/** + * Accumulates the attributes for a single record. + * + * @remarks + * A duplicated key has undefined behaviour in OTLP, so the writer tracks the keys it has already + * emitted. When a key is written more than once the later value replaces the earlier one (keeping + * the original position), which implements the precedence where the more specific source wins. This + * matters in practice because Application Insights copies the same custom properties into both + * `baseData.properties` and the Part C data of an item. + */ +export interface IAttributeWriter { + /** + * The attributes written so far. + */ + attrs: IOtlpKeyValue[]; + + /** + * A map of each attribute key to its index within {@link IAttributeWriter.attrs}. + */ + keys: { [key: string]: number }; + + options: IAttrOptions; +} + +/** + * Creates an attribute writer. + * @param options - The options controlling how values are converted. + * @returns The new writer. + */ +export function createAttributeWriter(options?: IAttrOptions): IAttributeWriter { + return { + attrs: [], + keys: {}, + options: options || { piiMode: "drop" } + }; +} + +/** + * Converts a single JavaScript value into the OTLP `AnyValue` representation. + * + * @remarks + * Integers are emitted as `intValue` (a decimal string) only when they can be represented exactly, + * everything else that is numeric is emitted as a `doubleValue`. Values that are not directly + * representable (functions, symbols, cyclic objects) fall back to a string representation so that a + * single unusual property can never fail the conversion of an entire batch. + * + * @param value - The value to convert. + * @returns The OTLP `AnyValue` for the supplied value. + */ +export function toAnyValue(value: any): IOtlpAnyValue { + if (isString(value)) { + return { stringValue: value }; + } + + if (isBoolean(value)) { + return { boolValue: value }; + } + + if (isNumber(value)) { + if (isNaN(value) || !isFinite(value)) { + // Neither NaN nor +/-Infinity are representable in JSON, preserve them as strings + return { stringValue: "" + value }; + } + + if (mathFloor(value) === value && value <= MAX_SAFE_INT && value >= -MAX_SAFE_INT) { + return { intValue: "" + value }; + } + + return { doubleValue: value }; + } + + if (isArray(value)) { + let values: IOtlpAnyValue[] = []; + arrForEach(value, (entry) => { + values.push(toAnyValue(entry)); + }); + + return { arrayValue: { values: values } }; + } + + if (isNullOrUndefined(value)) { + // An AnyValue with no member set represents an empty value + return {}; + } + + // Dates are far more useful as an ISO string than as an opaque key / value list + if ((value as Date).toISOString && (value as Date).getTime) { + return { stringValue: (value as Date).toISOString() }; + } + + if (typeof value === "object") { + try { + let values: IOtlpKeyValue[] = []; + objForEachKey(value, (key, entry) => { + values.push({ key: key, value: toAnyValue(entry) }); + }); + + return { kvlistValue: { values: values } }; + } catch (e) { + // Fall through to the string representation below + } + } + + return { stringValue: safeStringify(value) }; +} + +/** + * Recursively strips or replaces any nested Common Schema PII marked value. + * + * @remarks + * A property value can itself be an object containing `IEventProperty` members. Those never pass + * through the top level resolver, so without this they would be serialized verbatim by + * {@link toAnyValue} and a PII marked value nested one level down would leak even in `drop` mode. + * + * @param value - The value to sanitize. + * @param options - The attribute options carrying the PII mode. + * @param depth - Guards against a pathologically deep or cyclic object. + * @returns The sanitized value, or `undefined` when the whole value must be dropped. + */ +export function sanitizeNested(value: any, options: IAttrOptions, depth?: number): any { + let level = depth || 0; + if (level > 8 || !value || typeof value !== "object") { + return value; + } + + if (isArray(value)) { + let result: any[] = []; + arrForEach(value, (entry) => { + let sanitized = sanitizeNested(entry, options, level + 1); + if (!isNullOrUndefined(sanitized)) { + result.push(sanitized); + } + }); + + return result; + } + + // An IEventProperty nested inside another value + if (!isNullOrUndefined(value.value) && isNumber(value.kind)) { + if (value.kind <= 0) { + return sanitizeNested(value.value, options, level + 1); + } + + if (value.kind === PII_DROP_VALUE || options.piiMode === "drop") { + return undefined; + } + + if (options.piiMode === "hash") { + return hashValue(value.value); + } + + return sanitizeNested(value.value, options, level + 1); + } + + let result: any = {}; + objForEachKey(value, (key, entry) => { + let sanitized = sanitizeNested(entry, options, level + 1); + if (!isNullOrUndefined(sanitized)) { + result[key] = sanitized; + } + }); + + return result; +} + +/** + * Serializes the supplied value, falling back to its string representation when it cannot be + * serialized (for example when it is cyclic). + * @param value - The value to serialize. + * @returns The serialized value. + */ +export function safeStringify(value: any): string { + try { + let result = JSON.stringify(value); + return isNullOrUndefined(result) ? "" + value : result; + } catch (e) { + return "" + value; + } +} + +/** + * Produces a stable, non reversible hash of the supplied value. + * @remarks + * This is a fast 32bit hash used only to replace a value that must not be exported verbatim while + * still allowing occurrences of the same value to be correlated. It is explicitly NOT a + * cryptographic hash and must not be relied on as one. + * @param value - The value to hash. + * @returns The hash as a hex string. + */ +export function hashValue(value: any): string { + let str = isString(value) ? value : safeStringify(value); + let hash = 0; + + for (let lp = 0; lp < str.length; lp++) { + // hash * 31 + char, kept within 32 bits + hash = ((hash << 5) - hash) + str.charCodeAt(lp); + hash = hash | 0; + } + + return (hash >>> 0).toString(16); +} + +/** + * The Common Schema `eEventPropertyType` values. + */ +const enum ePropertyType { + Unspecified = 0, + String = 1, + Int32 = 2, + UInt32 = 3, + Int64 = 4, + UInt64 = 5, + Double = 6, + Bool = 7, + Guid = 8, + DateTime = 9 +} + +/** + * Converts a value using the type that the Common Schema declared for it. + * + * @remarks + * A Common Schema property may declare its type separately from its value -- an `Int64` for example + * is commonly carried as a string so that it does not lose precision in JavaScript. Ignoring the + * declared type would silently turn such a value into an OTLP `stringValue`, losing the fact that it + * is a number. + * + * @param value - The raw value. + * @param propertyType - The declared `eEventPropertyType`. + * @returns The OTLP `AnyValue`, or `null` when the declared type does not apply. + */ +export function toTypedAnyValue(value: any, propertyType: number): IOtlpAnyValue { + if (isNullOrUndefined(propertyType) || propertyType === ePropertyType.Unspecified) { + return null; + } + + switch (propertyType) { + case ePropertyType.Int32: + case ePropertyType.UInt32: + case ePropertyType.Int64: + case ePropertyType.UInt64: { + // An integer is represented in OTLP as a decimal string, which is exactly how a Common Schema + // Int64 already arrives, so a string value is passed through rather than parsed (parsing + // would lose precision for values beyond 2^53). + if (isString(value) && /^-?[0-9]+$/.test(value)) { + return { intValue: value }; + } + + if (isNumber(value) && mathFloor(value) === value) { + return { intValue: "" + value }; + } + + break; + } + case ePropertyType.Double: { + let numeric = isNumber(value) ? value : parseFloat(value); + if (!isNaN(numeric) && isFinite(numeric)) { + return { doubleValue: numeric }; + } + + break; + } + case ePropertyType.Bool: { + if (isBoolean(value)) { + return { boolValue: value }; + } + + if (value === "true" || value === "false") { + return { boolValue: value === "true" }; + } + + break; + } + case ePropertyType.String: + case ePropertyType.Guid: + case ePropertyType.DateTime: { + // A guid and a datetime have no dedicated OTLP representation, they stay strings + if (isString(value)) { + return { stringValue: value }; + } + + if ((value as Date) && (value as Date).toISOString) { + return { stringValue: (value as Date).toISOString() }; + } + + break; + } + } + + return null; +} + +/** + * Writes an already converted `AnyValue`, replacing any earlier value written for the same key. + */ +function _write(writer: IAttributeWriter, key: string, value: IOtlpAnyValue): void { + let existing = writer.keys[key]; + if (isNullOrUndefined(existing)) { + writer.keys[key] = writer.attrs.length; + writer.attrs.push({ key: key, value: value }); + } else { + // The later value wins, but the original position is retained so that the more meaningful + // ordering (the semantic convention attributes first) is preserved. + writer.attrs[existing].value = value; + } +} + +/** + * The result of resolving a raw value: the value to emit plus any Common Schema declared type. + */ +interface IResolvedValue { + v: any; + t?: number; +} + +/** + * The `eValueKind` value that means the value must be removed entirely rather than hashed. + */ +const PII_DROP_VALUE = 15; + +/** + * Applies the PII policy to a value that carries a Common Schema value kind. + * @returns The value to emit, or `undefined` when it must be dropped. + */ +function _applyPiiPolicy(writer: IAttributeWriter, key: string, kind: number, inner: any): any { + // `Pii_DropValue` documents itself as "Drops the value altogether, rather than hashing", so it + // overrides the configured mode -- keeping or hashing it would violate the marker's contract. + if (kind === PII_DROP_VALUE) { + return undefined; + } + + let piiMode = writer.options.piiMode; + if (piiMode === "keep") { + _write(writer, MS_PREFIX + "pii." + key, { intValue: "" + kind }); + return inner; + } + + if (piiMode === "hash") { + return hashValue(inner); + } + + return undefined; +} + +/** + * Unwraps a Common Schema `IEventProperty` style value, applying the configured PII policy. + * + * @remarks + * The Common Schema carries per field PII and customer content markers (`kind`) which have no OTLP + * equivalent, so a marked value must either be removed, replaced or explicitly flagged. It also + * carries a declared `propertyType`, which is returned so that the declared type is not lost. + * + * @returns The resolved value, or `null` when the value must be dropped entirely. + */ +function _resolveValue(writer: IAttributeWriter, key: string, value: any): IResolvedValue | null { + if (!value || typeof value !== "object" || isArray(value) || isNullOrUndefined(value.value)) { + return { v: value }; + } + + let kind = value.kind; + let propertyType = value.propertyType; + let hasKind = isNumber(kind); + if (!hasKind && isNullOrUndefined(propertyType)) { + // Not an IEventProperty, treat it as a plain object value + return { v: value }; + } + + let inner = value.value; + if (!hasKind || kind <= 0) { + return { v: inner, t: propertyType }; + } + + let resolved = _applyPiiPolicy(writer, key, kind, inner); + if (isNullOrUndefined(resolved)) { + return null; + } + + // A hashed value is a string regardless of the declared type + return { v: resolved, t: resolved === inner ? propertyType : undefined }; +} + +/** + * Writes a single attribute, ignoring values that carry no information. + * @param writer - The attribute writer. + * @param key - The attribute key. + * @param value - The attribute value. + * @returns `true` when the attribute was written. + */ +export function addAttribute(writer: IAttributeWriter, key: string, value: any): boolean { + if (!key || isNullOrUndefined(value) || value === "") { + return false; + } + + let resolved = _resolveValue(writer, key, value); + if (!resolved || isNullOrUndefined(resolved.v) || resolved.v === "") { + return false; + } + + // A nested object may itself contain PII marked members, which the top level resolver never sees + let emit = typeof resolved.v === "object" ? sanitizeNested(resolved.v, writer.options) : resolved.v; + if (isNullOrUndefined(emit)) { + return false; + } + + // Honour the Common Schema declared type where there is one, otherwise infer from the value + let anyValue = isNullOrUndefined(resolved.t) ? null : toTypedAnyValue(emit, resolved.t); + _write(writer, key, anyValue || toAnyValue(emit)); + + return true; +} + +/** + * Writes every own property of the supplied map. + * @param writer - The attribute writer. + * @param values - The map of values to write, may be null. + * @param prefix - An optional prefix applied to every key. + * @param exclude - An optional map of keys (without the prefix applied) that should be skipped. + */ +export function addAttributes(writer: IAttributeWriter, values: { [key: string]: any }, + prefix?: string, exclude?: { [key: string]: number }): void { + if (!values) { + return; + } + + objForEachKey(values, (key, value) => { + if (exclude && exclude[key]) { + return; + } + + addAttribute(writer, prefix ? prefix + key : key, value); + }); +} diff --git a/channels/otlp-channel-js/src/convert/IdUtils.ts b/channels/otlp-channel-js/src/convert/IdUtils.ts new file mode 100644 index 000000000..52048aff9 --- /dev/null +++ b/channels/otlp-channel-js/src/convert/IdUtils.ts @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { getInst, isString, strLower } from "@nevware21/ts-utils"; + +const TRACE_ID_LEN = 32; +const SPAN_ID_LEN = 16; +const HEX_ZEROS = "00000000000000000000000000000000"; +const SEPARATORS = /[-:\s]/g; +const NON_HEX = /[^0-9a-f]/; + +function _normalizeId(value: any, length: number): string { + if (!isString(value) || !value) { + return null; + } + + // Identifiers that originate from a `traceparent` header or from user supplied context may + // include separators or uppercase characters, so those are normalized away. Anything that still + // contains a non hex character is rejected rather than coerced, because silently turning an + // arbitrary string into a plausible looking identifier would fabricate correlation that does not + // exist. + let id = strLower(value).replace(SEPARATORS, ""); + if (!id || NON_HEX.test(id)) { + return null; + } + + if (id.length > length) { + id = id.substring(0, length); + } else if (id.length < length) { + id = HEX_ZEROS.substring(0, length - id.length) + id; + } + + // An all zero id is explicitly invalid in the OpenTelemetry specification + if (id === HEX_ZEROS.substring(0, length)) { + return null; + } + + return id; +} + +/** + * Normalizes the supplied value into a valid OTLP trace id (32 lowercase hex characters). + * @param value - The value to normalize. + * @returns The normalized trace id, or `null` when the value cannot represent a valid trace id. + */ +export function normalizeTraceId(value: any): string { + return _normalizeId(value, TRACE_ID_LEN); +} + +/** + * Normalizes the supplied value into a valid OTLP span id (16 lowercase hex characters). + * @param value - The value to normalize. + * @returns The normalized span id, or `null` when the value cannot represent a valid span id. + */ +export function normalizeSpanId(value: any): string { + return _normalizeId(value, SPAN_ID_LEN); +} + +/** + * Extracts a span id from an Application Insights hierarchical operation id. + * + * @remarks + * The dependency and request telemetry produced by this SDK carries an id in the W3C derived + * `|.` form (see `ajaxRecord.ts`), which is not itself a valid OTLP span id. The + * embedded span id is the identifier that any child telemetry will reference as its parent, so it + * must be used rather than discarded -- generating a new id here would silently break the parent / + * child relationships in the exported trace. + * + * @param value - The identifier to parse. + * @returns The embedded span id, or `null` when the value does not carry one. + */ +export function extractSpanId(value: any): string { + if (!isString(value) || !value) { + return null; + } + + if (value.charAt(0) !== "|" && value.indexOf(".") === -1) { + // A plain identifier, let the normal normalization handle it + return normalizeSpanId(value); + } + + let parts = value.replace(/^\|/, "").split("."); + let segments: string[] = []; + for (let lp = 0; lp < parts.length; lp++) { + if (parts[lp]) { + segments.push(parts[lp]); + } + } + + // `|.` identifies an operation rather than a span, so there is no span id to extract + if (segments.length < 2) { + return null; + } + + return normalizeSpanId(segments[segments.length - 1]); +} + +/** + * Splits a target that may be either a bare host or an absolute url. + * + * @remarks + * The automatically collected dependency telemetry sets `target` to the absolute url of the request + * (`ajaxRecord.ts`), but the OpenTelemetry semantic conventions require `server.address` to be the + * host on its own with the port reported separately, so the value has to be split rather than copied + * verbatim. + * + * @param target - The dependency target. + * @returns The host, the port (when present) and the url (when the target was an absolute url). + */ +export function parseTarget(target: any): { host: string, port: number, url: string } { + let result = { host: null as string, port: null as number, url: null as string }; + + if (!isString(target) || !target) { + return result; + } + + // scheme://host[:port][/path] + let matches = /^([a-z][a-z0-9+.-]*):\/\/([^/?#:]+)(?::(\d+))?/i.exec(target); + if (matches) { + result.host = matches[2]; + result.url = target; + if (matches[3]) { + result.port = +matches[3]; + } + + return result; + } + + // host[:port] without a scheme + let hostPort = /^([^/?#:]+):(\d+)$/.exec(target); + if (hostPort) { + result.host = hostPort[1]; + result.port = +hostPort[2]; + + return result; + } + + result.host = target; + + return result; +} + +/** + * Generates a random hex identifier of the requested length, preferring the platform's cryptographic + * random source when it is available. + */ +function _randomHex(length: number): string { + let result = ""; + let crypto: any = getInst("crypto") || getInst("msCrypto"); + + if (crypto && crypto.getRandomValues) { + // Two hex characters per byte + let bytes = new Uint8Array(length / 2); + crypto.getRandomValues(bytes); + for (let lp = 0; lp < bytes.length; lp++) { + let hex = bytes[lp].toString(16); + result += hex.length === 1 ? "0" + hex : hex; + } + } else { + while (result.length < length) { + // 8 hex characters at a time + let part = ((Math.random() * 0x100000000) >>> 0).toString(16); + result += HEX_ZEROS.substring(0, 8 - part.length) + part; + } + + result = result.substring(0, length); + } + + // A zero identifier is invalid, so force at least one non zero character + if (result === HEX_ZEROS.substring(0, length)) { + result = "1" + result.substring(1); + } + + return result; +} + +/** + * Generates a new random OTLP trace id. + * @returns A 32 character lowercase hex trace id. + */ +export function generateTraceId(): string { + return _randomHex(TRACE_ID_LEN); +} + +/** + * Generates a new random OTLP span id. + * + * @remarks + * This is required because not every Application Insights telemetry type carries a usable span + * identifier -- a page view for example has a page view id rather than a span id -- yet a span + * cannot be exported to OTLP without a valid `spanId`. + * + * @returns A 16 character lowercase hex span id. + */ +export function generateSpanId(): string { + return _randomHex(SPAN_ID_LEN); +} diff --git a/channels/otlp-channel-js/src/convert/ItemConverter.ts b/channels/otlp-channel-js/src/convert/ItemConverter.ts new file mode 100644 index 000000000..dbbb2eda3 --- /dev/null +++ b/channels/otlp-channel-js/src/convert/ItemConverter.ts @@ -0,0 +1,660 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + CtxTagKeys, EventDataType, ExceptionDataType, ITelemetryItem, MetricDataType, PageViewDataType, PageViewPerformanceDataType, + RemoteDependencyDataType, RequestDataType, TraceDataType, eSeverityLevel +} from "@microsoft/applicationinsights-core-js"; +import { arrForEach, isArray, isNullOrUndefined, isNumber, isString, objForEachKey, strLower, strStartsWith } from "@nevware21/ts-utils"; +import { eOtlpSeverityNumber, eOtlpSignal, eOtlpSpanKind, eOtlpStatusCode } from "../Enums"; +import { IOtlpChannelConfig } from "../Interfaces/IOtlpChannelConfig"; +import { IOtlpLogRecord, IOtlpSpan } from "../Interfaces/IOtlpTypes"; +import { + ATTR_DB_STATEMENT, ATTR_DB_SYSTEM, ATTR_EVENT_NAME, ATTR_EXCEPTION_MESSAGE, ATTR_EXCEPTION_STACKTRACE, ATTR_EXCEPTION_TYPE, + ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_PEER_SERVICE, ATTR_RPC_SYSTEM, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, + ATTR_URL_FULL, EXT_DT, EXT_METADATA, EXT_TRACE, MS_EXT_PREFIX, MS_PREFIX +} from "../InternalConstants"; +import { IAttrOptions, IAttributeWriter, addAttribute, addAttributes, createAttributeWriter, safeStringify } from "./AttributeBuilder"; +import { extractSpanId, generateSpanId, generateTraceId, normalizeSpanId, normalizeTraceId, parseTarget } from "./IdUtils"; +import { addMillisToUnixNanoStr, epochMillisToUnixNanoStr, parseDurationMs, toEpochMillis } from "./TimeUtils"; + +/** + * A map of keys that should be skipped when copying values. + */ +export interface IKeyMap { + [key: string]: number; +} + +/** + * The result of converting a single telemetry item. + */ +export interface IOtlpRecord { + /** + * Which OTLP signal (and therefore which endpoint and envelope) this record belongs to. + */ + signal: eOtlpSignal; + + /** + * The converted record, only populated when the channel is not pre-serializing. + */ + record?: IOtlpSpan | IOtlpLogRecord; + + /** + * The serialized record, populated when the channel is pre-serializing. + */ + json?: string; +} + +/** + * The context supplied to the converter, created once per configuration rather than per item. + */ +export interface IConvertCtx { + config: IOtlpChannelConfig; + + /** + * The tag keys that have been promoted onto the resource and so must not be repeated on every + * record. + */ + resourceTagKeys: IKeyMap; + + attrOptions: IAttrOptions; +} + +/** + * The `baseData` members that are mapped explicitly for a span and therefore must not be repeated as + * a `microsoft.*` attribute. + */ +const _consumedSpanFields: IKeyMap = { + name: 1, id: 1, duration: 1, success: 1, startTime: 1, properties: 1, measurements: 1, + ver: 1, responseCode: 1, resultCode: 1, url: 1, target: 1, data: 1, type: 1 +}; + +/** + * The `baseData` members that are mapped explicitly for a log record. + */ +const _consumedLogFields: IKeyMap = { + name: 1, message: 1, severityLevel: 1, properties: 1, measurements: 1, ver: 1, + exceptions: 1, metrics: 1, startTime: 1, id: 1, duration: 1, url: 1 +}; + +/** + * The Part A extensions that are consumed directly rather than being flattened into attributes. + */ +const _consumedExts: IKeyMap = {}; +_consumedExts[EXT_DT] = 1; +_consumedExts[EXT_METADATA] = 1; + +const METHOD_NAME_REGEX = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|TRACE|CONNECT)\s+(\S*)/; +const IN_PROC = "InProc"; +const MS_STATUS_DESCRIPTION = "_MS.status.description"; + +/** + * The `baseType` used by the 1DS Common Schema for a native OpenTelemetry span + * (`Ms.Web.Span`), produced by `createExtendedTelemetryItemFromSpan` in the core. + */ +export const OTelSpanDataType = "OTelSpan"; + +/** + * The Part B members of an `OTelSpan` that are mapped explicitly. + */ +const _consumedOTelSpanFields: IKeyMap = { + name: 1, kind: 1, startTime: 1, duration: 1, success: 1, parentId: 1, traceState: 1, + statusMessage: 1, httpMethod: 1, httpUrl: 1, httpStatusCode: 1, dbSystem: 1, dbStatement: 1, + rpcSystem: 1, properties: 1, measurements: 1, ver: 1 +}; + +/** + * Translates the SDK's span kind into the OTLP `SpanKind`. + * + * @remarks + * These two enumerations are NOT the same. The SDK's `eOTelSpanKind` starts at `INTERNAL = 0` + * whereas the OTLP `SpanKind` reserves 0 for `UNSPECIFIED` and starts at `INTERNAL = 1`. Copying the + * value across unchanged would shift every span kind by one, silently turning every INTERNAL span + * into UNSPECIFIED, every SERVER span into INTERNAL and so on. + * + * @param sdkKind - The `eOTelSpanKind` value carried by the telemetry. + * @returns The equivalent OTLP `SpanKind`. + */ +export function toOtlpSpanKind(sdkKind: any): eOtlpSpanKind { + if (!isNumber(sdkKind) || sdkKind < 0 || sdkKind > 4) { + return eOtlpSpanKind.INTERNAL; + } + + // eOTelSpanKind INTERNAL(0) SERVER(1) CLIENT(2) PRODUCER(3) CONSUMER(4) + // OTLP SpanKind INTERNAL(1) SERVER(2) CLIENT(3) PRODUCER(4) CONSUMER(5) + return (sdkKind + 1) as eOtlpSpanKind; +} + +/** + * Maps a `baseType` onto the OTLP signal that carries it. + * @param baseType - The `baseType` of the telemetry item. + * @param config - The channel configuration. + * @returns The signal to export the item as, or `null` when the item should be ignored. + */ +export function getSignal(baseType: string, config: IOtlpChannelConfig): eOtlpSignal | null { + if (baseType === RequestDataType || baseType === RemoteDependencyDataType || + baseType === OTelSpanDataType) { + return eOtlpSignal.Span; + } + + if (baseType === PageViewDataType) { + return config.pageViewAs === "log" ? eOtlpSignal.Log : eOtlpSignal.Span; + } + + if (baseType === TraceDataType || baseType === ExceptionDataType || baseType === EventDataType || + baseType === PageViewPerformanceDataType) { + return eOtlpSignal.Log; + } + + if (baseType === MetricDataType) { + // The OTLP metrics signal is not supported yet, optionally represent them as log records so + // that the data is not silently lost. + return config.metricsAsLogs ? eOtlpSignal.Log : null; + } + + // An unrecognised type is still more useful as a log record than it is dropped + return baseType ? eOtlpSignal.Log : null; +} + +function _tag(item: ITelemetryItem, key: string): any { + let tags = item.tags; + return tags ? tags[key] : undefined; +} + +function _getExt(item: ITelemetryItem, name: string): any { + let ext = item.ext; + return (ext ? ext[name] : null) || {}; +} + +/** + * Resolves the start time of the item as a `timeUnixNano` decimal string. + */ +function _getStartTime(item: ITelemetryItem, baseData: any): string { + // `createTelemetryItemFromSpan` records the true span start on the baseData which is more + // accurate than the item time (the time at which the item was processed). + let millis = toEpochMillis(baseData ? baseData.startTime : null); + if (isNullOrUndefined(millis)) { + millis = toEpochMillis(item.time); + } + + if (isNullOrUndefined(millis)) { + millis = (new Date()).getTime(); + } + + return epochMillisToUnixNanoStr(millis); +} + +/** + * Adds the attributes that are common to both signals, applying the documented precedence: unmapped + * Part B fields, Part B properties, Part B measurements, Part C, the Part A extensions and finally + * any tag that was not promoted onto the resource. + * + * @remarks + * The writer replaces (rather than repeats) a key that has already been written, so where the same + * custom property appears in more than one of these sources -- which Application Insights routinely + * does for `baseData.properties` and Part C -- only a single attribute is emitted. + */ +function _addCommonAttributes(writer: IAttributeWriter, item: ITelemetryItem, baseData: any, + consumed: IKeyMap, ctx: IConvertCtx): void { + + if (baseData) { + // 1. Any baseData member that was not mapped explicitly + objForEachKey(baseData, (key, value) => { + if (consumed[key]) { + return; + } + + addAttribute(writer, MS_PREFIX + key, value); + }); + + // 2. The custom properties. For items created from a span these carry the original + // OpenTelemetry attributes, so they are emitted using their original keys. + addAttributes(writer, baseData.properties); + + // 3. The custom measurements + addAttributes(writer, baseData.measurements); + } + + // 4. Part C + addAttributes(writer, item.data); + + // 5. The Part A extensions, flattened + let ext = item.ext; + if (ext) { + objForEachKey(ext, (extName, extValue) => { + if (_consumedExts[extName] || !extValue) { + return; + } + + addAttributes(writer, extValue, MS_EXT_PREFIX + extName + "."); + }); + } + + // 6. Any tag that was not promoted onto the resource + addAttributes(writer, item.tags, null, ctx.resourceTagKeys); +} + +function _isSqlType(dependencyType: string): boolean { + let lowered = strLower(dependencyType); + return lowered.indexOf("sql") !== -1 || lowered === "mysql" || lowered === "postgresql" || lowered === "mongodb"; +} + +function _convertSpan(item: ITelemetryItem, ctx: IConvertCtx): IOtlpSpan { + let baseData = item.baseData || {}; + let baseType = item.baseType; + let dt = _getExt(item, EXT_DT); + let traceExt = _getExt(item, EXT_TRACE); + let isRequest = baseType === RequestDataType; + let isPageView = baseType === PageViewDataType; + + let span: IOtlpSpan = {}; + + // Both identifiers are required by the OpenTelemetry specification, so where the telemetry does + // not carry a usable value (a page view has a page view id rather than a span id, for example) + // one is generated rather than exporting an invalid span. + span.traceId = normalizeTraceId(dt.traceId || traceExt.traceID || _tag(item, CtxTagKeys.operationId)) || + generateTraceId(); + + let spanId = normalizeSpanId(dt.spanId) || extractSpanId(baseData.id); + let generatedSpanId = false; + if (!spanId) { + spanId = generateSpanId(); + generatedSpanId = true; + } + span.spanId = spanId; + + let parentSpanId = normalizeSpanId(traceExt.parentID || _tag(item, CtxTagKeys.operationParentId)); + // A span cannot be its own parent, which happens when the operation parent id has been set to + // the id of this span + if (parentSpanId && parentSpanId !== spanId) { + span.parentSpanId = parentSpanId; + } + + if (isNumber(dt.traceFlags)) { + span.flags = dt.traceFlags; + } + + span.name = baseData.name || item.name; + + let dependencyType = isString(baseData.type) ? baseData.type : ""; + let isOTelSpan = baseType === OTelSpanDataType; + + if (isOTelSpan) { + // A native Common Schema span already carries its own kind, translated because the two + // enumerations do not share the same numbering (see toOtlpSpanKind). + span.kind = toOtlpSpanKind(baseData.kind); + } else if (isRequest) { + span.kind = eOtlpSpanKind.SERVER; + } else if (isPageView || strStartsWith(dependencyType, IN_PROC)) { + span.kind = eOtlpSpanKind.INTERNAL; + } else { + span.kind = eOtlpSpanKind.CLIENT; + } + + if (isOTelSpan) { + let otelParent = normalizeSpanId(baseData.parentId); + if (otelParent && otelParent !== spanId) { + span.parentSpanId = otelParent; + } + + if (baseData.traceState) { + span.traceState = baseData.traceState; + } + } + + let startUnixNano = _getStartTime(item, baseData); + span.startTimeUnixNano = startUnixNano; + span.endTimeUnixNano = addMillisToUnixNanoStr(startUnixNano, parseDurationMs(baseData.duration)); + + let success = baseData.success; + let status: any = { + code: success === false ? eOtlpStatusCode.ERROR : (success === true ? eOtlpStatusCode.OK : eOtlpStatusCode.UNSET) + }; + + let properties = baseData.properties; + let statusMessage = baseData.statusMessage || (properties ? properties[MS_STATUS_DESCRIPTION] : null); + if (statusMessage) { + status.message = statusMessage; + } + span.status = status; + + let writer = createAttributeWriter(ctx.attrOptions); + + if (generatedSpanId && baseData.id) { + // Retain whatever identifier the telemetry did carry so that the exported span can still be + // correlated back to the original Application Insights item. + addAttribute(writer, MS_PREFIX + "telemetry_id", baseData.id); + } + + if (isOTelSpan) { + // A native Common Schema span carries the semantic values in dedicated Part B members + addAttribute(writer, ATTR_HTTP_REQUEST_METHOD, baseData.httpMethod); + addAttribute(writer, ATTR_URL_FULL, baseData.httpUrl); + addAttribute(writer, ATTR_DB_SYSTEM, baseData.dbSystem); + addAttribute(writer, ATTR_DB_STATEMENT, baseData.dbStatement); + addAttribute(writer, ATTR_RPC_SYSTEM, baseData.rpcSystem); + + if (!isNullOrUndefined(baseData.httpStatusCode) && baseData.httpStatusCode !== "") { + let httpStatus = +baseData.httpStatusCode; + addAttribute(writer, isNaN(httpStatus) ? MS_PREFIX + "http_status_code" : ATTR_HTTP_RESPONSE_STATUS_CODE, + isNaN(httpStatus) ? baseData.httpStatusCode : httpStatus); + } + + let otelUrl = parseTarget(baseData.httpUrl); + addAttribute(writer, ATTR_SERVER_ADDRESS, otelUrl.host); + addAttribute(writer, ATTR_SERVER_PORT, otelUrl.port); + + _addCommonAttributes(writer, item, baseData, _consumedOTelSpanFields, ctx); + + if (writer.attrs.length) { + span.attributes = writer.attrs; + } + + return span; + } + + // Re-derive the semantic convention attributes that `createTelemetryItemFromSpan` folded into + // the dedicated baseData fields. + let name = baseData.name; + if (isString(name)) { + let matches = METHOD_NAME_REGEX.exec(name); + if (matches) { + addAttribute(writer, ATTR_HTTP_REQUEST_METHOD, matches[1]); + } + } + + // The automatically collected dependency telemetry puts the absolute url in `target`, but the + // semantic conventions require the host on its own with the port reported separately. + let target = parseTarget(baseData.target); + + // An explicitly supplied url always wins over the one recovered from the target + let fullUrl = (isRequest || isPageView ? baseData.url : (baseData.data || baseData.url)) || target.url; + addAttribute(writer, ATTR_URL_FULL, fullUrl); + addAttribute(writer, ATTR_SERVER_ADDRESS, target.host); + addAttribute(writer, ATTR_SERVER_PORT, target.port); + + let responseCode = isRequest ? baseData.responseCode : baseData.resultCode; + if (!isNullOrUndefined(responseCode) && responseCode !== "") { + // A dependency result code is not always numeric, it may carry a gRPC or database status + let numeric = +responseCode; + if (isNaN(numeric)) { + addAttribute(writer, MS_PREFIX + "result_code", responseCode); + } else { + addAttribute(writer, ATTR_HTTP_RESPONSE_STATUS_CODE, numeric); + } + } + + if (dependencyType) { + addAttribute(writer, MS_PREFIX + "dependency.type", dependencyType); + + if (_isSqlType(dependencyType)) { + addAttribute(writer, ATTR_DB_SYSTEM, dependencyType); + addAttribute(writer, ATTR_DB_STATEMENT, baseData.data); + } else if (!strStartsWith(dependencyType, "Http") && target.host) { + addAttribute(writer, ATTR_PEER_SERVICE, target.host); + } + } + + if (isPageView) { + addAttribute(writer, MS_PREFIX + "page_view.id", baseData.id); + } + + // The Part B and Common Schema schema versions, which would otherwise be lost + addAttribute(writer, MS_PREFIX + "telemetry_type", baseType); + addAttribute(writer, MS_PREFIX + "schema_version", baseData.ver); + addAttribute(writer, MS_PREFIX + "common_schema.version", (item as any).ver); + + _addCommonAttributes(writer, item, baseData, _consumedSpanFields, ctx); + + if (writer.attrs.length) { + span.attributes = writer.attrs; + } + + return span; +} + +function _getSeverity(severityLevel: any, isException: boolean): number { + if (isNullOrUndefined(severityLevel)) { + return isException ? eOtlpSeverityNumber.ERROR : eOtlpSeverityNumber.INFO; + } + + switch (+severityLevel) { + case eSeverityLevel.Verbose: + return eOtlpSeverityNumber.TRACE; + case eSeverityLevel.Information: + return eOtlpSeverityNumber.INFO; + case eSeverityLevel.Warning: + return eOtlpSeverityNumber.WARN; + case eSeverityLevel.Error: + return eOtlpSeverityNumber.ERROR; + case eSeverityLevel.Critical: + return eOtlpSeverityNumber.FATAL; + } + + return isException ? eOtlpSeverityNumber.ERROR : eOtlpSeverityNumber.INFO; +} + +function _getSeverityText(severityNumber: number): string { + switch (severityNumber) { + case eOtlpSeverityNumber.TRACE: + return "TRACE"; + case eOtlpSeverityNumber.DEBUG: + return "DEBUG"; + case eOtlpSeverityNumber.WARN: + return "WARN"; + case eOtlpSeverityNumber.ERROR: + return "ERROR"; + case eOtlpSeverityNumber.FATAL: + return "FATAL"; + } + + return "INFO"; +} + +function _getStack(exception: any): string { + if (!exception) { + return null; + } + + if (exception.stack) { + return exception.stack; + } + + let parsedStack = exception.parsedStack; + if (isArray(parsedStack)) { + // Reconstruct a conventional stack trace. Every IStackFrame member is included so that the + // structured frame can be read back out of the string. + let lines: string[] = []; + arrForEach(parsedStack, (frame) => { + if (!frame) { + return; + } + + let location = frame.fileName || ""; + if (!isNullOrUndefined(frame.line)) { + location += ":" + frame.line; + } + + let method = frame.method || frame.assembly || ""; + lines.push(" at " + method + (location ? " (" + location + ")" : "")); + }); + + return lines.join("\n"); + } + + return null; +} + +function _addMetrics(writer: IAttributeWriter, baseData: any): void { + let metrics = baseData.metrics; + if (!isArray(metrics)) { + return; + } + + arrForEach(metrics, (metric, idx) => { + if (!metric) { + return; + } + + let prefix = MS_PREFIX + "metric." + (idx === 0 ? "" : idx + "."); + addAttribute(writer, prefix + "name", metric.name); + addAttribute(writer, prefix + "value", metric.value); + addAttribute(writer, prefix + "count", metric.count); + addAttribute(writer, prefix + "min", metric.min); + addAttribute(writer, prefix + "max", metric.max); + addAttribute(writer, prefix + "stdDev", metric.stdDev); + // eDataPointType: 0 = Measurement, 1 = Aggregation + addAttribute(writer, prefix + "kind", metric.kind); + addAttribute(writer, prefix + "ns", metric.ns); + }); +} + +function _convertLog(item: ITelemetryItem, ctx: IConvertCtx, observedUnixNano: string): IOtlpLogRecord { + let baseData = item.baseData || {}; + let baseType = item.baseType; + let dt = _getExt(item, EXT_DT); + let traceExt = _getExt(item, EXT_TRACE); + let isException = baseType === ExceptionDataType; + let isEvent = baseType === EventDataType; + + let record: IOtlpLogRecord = {}; + + record.timeUnixNano = _getStartTime(item, baseData); + record.observedTimeUnixNano = observedUnixNano; + + let severityNumber = _getSeverity(baseData.severityLevel, isException); + record.severityNumber = severityNumber; + record.severityText = _getSeverityText(severityNumber); + + // Unlike a span, a log record that is not associated with a trace simply omits the identifiers + let traceId = normalizeTraceId(dt.traceId || traceExt.traceID || _tag(item, CtxTagKeys.operationId)); + if (traceId) { + record.traceId = traceId; + } + + // A log record is associated with the span that was active when it was created, which is the + // operation parent rather than an id of its own. + let spanId = normalizeSpanId(dt.spanId || traceExt.parentID || _tag(item, CtxTagKeys.operationParentId)); + if (spanId) { + record.spanId = spanId; + } + + if (isNumber(dt.traceFlags)) { + record.flags = dt.traceFlags; + } + + let writer = createAttributeWriter(ctx.attrOptions); + + if (isException) { + let exceptions = baseData.exceptions; + if (isArray(exceptions) && exceptions.length) { + let first = exceptions[0] || {}; + addAttribute(writer, ATTR_EXCEPTION_TYPE, first.typeName); + addAttribute(writer, ATTR_EXCEPTION_MESSAGE, first.message); + addAttribute(writer, ATTR_EXCEPTION_STACKTRACE, _getStack(first)); + + // The remaining IExceptionDetails members have no semantic convention equivalent, so they + // are preserved rather than dropped + addAttribute(writer, MS_PREFIX + "exception.id", first.id); + addAttribute(writer, MS_PREFIX + "exception.outer_id", first.outerId); + if (!isNullOrUndefined(first.hasFullStack)) { + addAttribute(writer, MS_PREFIX + "exception.has_full_stack", !!first.hasFullStack); + // The OpenTelemetry convention for a trimmed stack + addAttribute(writer, MS_PREFIX + "exception.stack_truncated", !first.hasFullStack); + } + + if (first.message) { + record.body = { stringValue: first.message }; + } + + // Nothing is dropped silently, any additional chained exception detail is preserved + if (exceptions.length > 1) { + addAttribute(writer, MS_PREFIX + "exception.details", safeStringify(exceptions)); + } + } + } else if (isEvent) { + let eventName = baseData.name || item.name; + if (eventName) { + record.eventName = eventName; + // `eventName` was only added to OTLP relatively recently, mirror it as an attribute so + // that older collectors still receive the name. + addAttribute(writer, ATTR_EVENT_NAME, eventName); + } + } else { + let message = baseData.message; + if (isNullOrUndefined(message)) { + message = baseData.name || item.name; + } + + if (!isNullOrUndefined(message) && message !== "") { + record.body = { stringValue: isString(message) ? message : safeStringify(message) }; + } + } + + if (baseType === MetricDataType) { + _addMetrics(writer, baseData); + } + + // These are carried by more than just a page view (a page view performance item has all three), + // so they are mapped for every log record rather than only for PageviewData. + addAttribute(writer, ATTR_URL_FULL, baseData.url); + + if (!isNullOrUndefined(baseData.duration)) { + addAttribute(writer, MS_PREFIX + "duration_ms", parseDurationMs(baseData.duration)); + } + + if (baseData.id) { + addAttribute(writer, MS_PREFIX + (baseType === PageViewDataType ? "page_view.id" : "telemetry_id"), + baseData.id); + } + + addAttribute(writer, MS_PREFIX + "telemetry_type", baseType); + // The Part B and Common Schema schema versions, which would otherwise be lost + addAttribute(writer, MS_PREFIX + "schema_version", baseData.ver); + addAttribute(writer, MS_PREFIX + "common_schema.version", (item as any).ver); + + _addCommonAttributes(writer, item, baseData, _consumedLogFields, ctx); + + if (writer.attrs.length) { + record.attributes = writer.attrs; + } + + return record; +} + +/** + * Converts a telemetry item into its final OTLP representation. + * + * @remarks + * This runs on the `processTelemetry` path, once per item. All of the mapping, attribute + * construction and (when `preSerialize` is enabled) JSON serialization happens here so that sending + * a batch performs no conversion work at all. + * + * @param item - The telemetry item to convert. + * @param ctx - The conversion context, created once per configuration. + * @param observedUnixNano - The time the item was received, as a `timeUnixNano` decimal string. + * @returns The converted record, or `null` when the item is not exportable. + */ +export function convertItem(item: ITelemetryItem, ctx: IConvertCtx, observedUnixNano: string): IOtlpRecord | null { + if (!item) { + return null; + } + + let signal = getSignal(item.baseType, ctx.config); + if (isNullOrUndefined(signal)) { + return null; + } + + let record: IOtlpSpan | IOtlpLogRecord = signal === eOtlpSignal.Span ? + _convertSpan(item, ctx) : + _convertLog(item, ctx, observedUnixNano); + + let result: IOtlpRecord = { signal: signal }; + if (ctx.config.preSerialize === false) { + result.record = record; + } else { + result.json = JSON.stringify(record); + } + + return result; +} diff --git a/channels/otlp-channel-js/src/convert/ResourceBuilder.ts b/channels/otlp-channel-js/src/convert/ResourceBuilder.ts new file mode 100644 index 000000000..623b0aae1 --- /dev/null +++ b/channels/otlp-channel-js/src/convert/ResourceBuilder.ts @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CtxTagKeys, ITelemetryItem } from "@microsoft/applicationinsights-core-js"; +import { objForEachKey } from "@nevware21/ts-utils"; +import { IOtlpChannelConfig } from "../Interfaces/IOtlpChannelConfig"; +import { IOtlpInstrumentationScope, IOtlpResource } from "../Interfaces/IOtlpTypes"; +import { + ATTR_BROWSER_LANGUAGE, ATTR_DEVICE_ID, ATTR_DEVICE_MODEL_NAME, ATTR_OS_TYPE, ATTR_OS_VERSION, ATTR_SERVICE_INSTANCE_ID, + ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, ATTR_TELEMETRY_SDK_LANGUAGE, ATTR_TELEMETRY_SDK_NAME, ATTR_TELEMETRY_SDK_VERSION, + DEFAULT_SCOPE_NAME, MS_PREFIX +} from "../InternalConstants"; +import { addAttribute, createAttributeWriter } from "./AttributeBuilder"; + +const SDK_NAME = "applicationinsights-web"; +const SDK_LANGUAGE = "webjs"; +const DEFAULT_SERVICE_NAME = "browser"; + +/** + * The set of tag keys that are promoted to resource attributes, and therefore should not also be + * emitted as record level attributes. + */ +export interface IResourceTagMap { + [tagKey: string]: number; +} + +/** + * A resource together with the pre-computed values needed to build an export payload for it. + */ +export interface IOtlpResourceInfo { + /** + * The key that identifies this resource, telemetry that resolves to the same key shares a batch. + */ + key: string; + + resource: IOtlpResource; + + scope: IOtlpInstrumentationScope; + + /** + * The serialized `"resource":{...}` fragment, pre-computed so that building an export payload + * requires no serialization of the resource. + */ + resourceJson: string; + + /** + * The serialized `"scope":{...}` fragment. + */ + scopeJson: string; +} + +/** + * The tags that are promoted onto the resource. Anything listed here is excluded from the per record + * attributes to avoid duplicating the value on every single record. + */ +export function getResourceTagKeys(): IResourceTagMap { + let keys: IResourceTagMap = {}; + keys[CtxTagKeys.cloudRole] = 1; + keys[CtxTagKeys.cloudRoleInstance] = 1; + keys[CtxTagKeys.applicationVersion] = 1; + keys[CtxTagKeys.deviceId] = 1; + keys[CtxTagKeys.deviceModel] = 1; + keys[CtxTagKeys.deviceOSVersion] = 1; + keys[CtxTagKeys.deviceLanguage] = 1; + keys[CtxTagKeys.internalSdkVersion] = 1; + + return keys; +} + +function _tag(item: ITelemetryItem, key: string): any { + let tags = item.tags; + return tags ? tags[key] : undefined; +} + +/** + * Computes the key that identifies the resource for the supplied telemetry item. Items that produce + * the same key are exported within the same `resourceSpans` / `resourceLogs` entry. + * + * @remarks + * In a browser the values that make up this key are effectively constant for the lifetime of the + * page, so this normally resolves to a single key and the associated resource is built exactly once. + * + * @param item - The telemetry item to compute the resource key for. + * @returns The resource key. + */ +export function getResourceKey(item: ITelemetryItem): string { + if (!item) { + return ""; + } + + let ext = item.ext || {}; + let osExt = ext["os"] || {}; + let webExt = ext["web"] || {}; + + // Every value that buildResourceInfo derives the resource from must appear here. Omitting one + // would let a later item inherit a cached resource built from a different context, while its own + // (promoted, and therefore suppressed from the record) tags are silently lost. + return [ + item.iKey || "", + _tag(item, CtxTagKeys.cloudRole) || "", + _tag(item, CtxTagKeys.cloudRoleInstance) || "", + _tag(item, CtxTagKeys.applicationVersion) || "", + _tag(item, CtxTagKeys.deviceId) || "", + _tag(item, CtxTagKeys.deviceModel) || "", + _tag(item, CtxTagKeys.deviceOS) || "", + _tag(item, CtxTagKeys.deviceOSVersion) || "", + _tag(item, CtxTagKeys.deviceLanguage) || "", + _tag(item, CtxTagKeys.internalSdkVersion) || "", + osExt["name"] || "", + osExt["osVer"] || "", + webExt["browserLang"] || "" + ].join("\u0001"); +} + +/** + * Builds the resource and instrumentation scope for the supplied telemetry item. + * + * @remarks + * This is comparatively expensive and is expected to be memoized by the caller against the value + * returned from {@link getResourceKey}. + * + * @param item - A representative telemetry item for the resource. + * @param config - The channel configuration. + * @param key - The resource key as returned by {@link getResourceKey}. + * @param sdkVersion - The version of this package, reported as the scope and sdk version. + * @returns The resource information including the pre-serialized fragments. + */ +export function buildResourceInfo(item: ITelemetryItem, config: IOtlpChannelConfig, key: string, sdkVersion: string): IOtlpResourceInfo { + let writer = createAttributeWriter(); + + let cloudRole = _tag(item, CtxTagKeys.cloudRole); + addAttribute(writer, ATTR_SERVICE_NAME, cloudRole || DEFAULT_SERVICE_NAME); + addAttribute(writer, ATTR_SERVICE_INSTANCE_ID, _tag(item, CtxTagKeys.cloudRoleInstance)); + addAttribute(writer, ATTR_SERVICE_VERSION, _tag(item, CtxTagKeys.applicationVersion)); + + addAttribute(writer, ATTR_TELEMETRY_SDK_NAME, SDK_NAME); + addAttribute(writer, ATTR_TELEMETRY_SDK_LANGUAGE, SDK_LANGUAGE); + addAttribute(writer, ATTR_TELEMETRY_SDK_VERSION, _tag(item, CtxTagKeys.internalSdkVersion) || sdkVersion); + + addAttribute(writer, ATTR_DEVICE_ID, _tag(item, CtxTagKeys.deviceId)); + addAttribute(writer, ATTR_DEVICE_MODEL_NAME, _tag(item, CtxTagKeys.deviceModel)); + + let ext = item.ext || {}; + let osExt = ext["os"] || {}; + addAttribute(writer, ATTR_OS_TYPE, osExt["name"] || _tag(item, CtxTagKeys.deviceOS)); + addAttribute(writer, ATTR_OS_VERSION, osExt["osVer"] || _tag(item, CtxTagKeys.deviceOSVersion)); + + let webExt = ext["web"] || {}; + addAttribute(writer, ATTR_BROWSER_LANGUAGE, webExt["browserLang"] || _tag(item, CtxTagKeys.deviceLanguage)); + + if (config.includeIKeyInResource) { + addAttribute(writer, MS_PREFIX + "instrumentation_key", item.iKey); + } + + // User supplied attributes are applied last so that they can override anything derived above. + // The writer replaces rather than repeats an existing key, because a duplicate key in an OTLP + // attribute list has undefined behaviour. + let overrides = config.resourceAttributes; + if (overrides) { + objForEachKey(overrides, (attrKey, value) => { + addAttribute(writer, attrKey, value); + }); + } + + let resource: IOtlpResource = { attributes: writer.attrs }; + let scope: IOtlpInstrumentationScope = { + name: config.scopeName || DEFAULT_SCOPE_NAME, + version: config.scopeVersion || sdkVersion + }; + + return { + key: key, + resource: resource, + scope: scope, + resourceJson: JSON.stringify(resource), + scopeJson: JSON.stringify(scope) + }; +} diff --git a/channels/otlp-channel-js/src/convert/TimeUtils.ts b/channels/otlp-channel-js/src/convert/TimeUtils.ts new file mode 100644 index 000000000..6ff9bb450 --- /dev/null +++ b/channels/otlp-channel-js/src/convert/TimeUtils.ts @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IOTelHrTime, millisToHrTime } from "@microsoft/applicationinsights-core-js"; +import { isNullOrUndefined, isNumber, isString, mathFloor } from "@nevware21/ts-utils"; + +const NANOS_PER_SECOND = 1e9; +const MILLIS_PER_SECOND = 1e3; +const NANOS_PER_MILLI = 1e6; +const ZERO_PAD = "000000000"; + +/** + * Converts a high resolution time into the OTLP `timeUnixNano` representation, which is the number + * of nanoseconds since the unix epoch encoded as a decimal string. + * + * @remarks + * This deliberately avoids all floating point arithmetic on the combined value. The number of + * nanoseconds since the epoch is currently around 1.75e18 which is far beyond + * `Number.MAX_SAFE_INTEGER` (~9.0e15), so computing `seconds * 1e9 + nanos` as a number silently + * loses roughly 3 digits of precision. `BigInt` is not available in the ES5 environments that this + * SDK supports, so the value is composed as a string from the (individually safe) seconds and + * nanoseconds components instead. + * + * @param hrTime - The high resolution `[seconds, nanoseconds]` time to convert. + * @returns The number of nanoseconds since the unix epoch as a decimal string. + */ +export function hrTimeToUnixNanoStr(hrTime: IOTelHrTime): string { + if (!hrTime) { + return "0"; + } + + let seconds = hrTime[0] || 0; + let nanos = hrTime[1] || 0; + + // Normalize any overflow / underflow in the nanoseconds component so that the string + // concatenation below stays correct. + if (nanos >= NANOS_PER_SECOND || nanos < 0) { + let extraSeconds = mathFloor(nanos / NANOS_PER_SECOND); + seconds += extraSeconds; + nanos -= extraSeconds * NANOS_PER_SECOND; + } + + // Negative times cannot be represented, clamp to the epoch + if (seconds < 0) { + return "0"; + } + + let nanoStr = "" + nanos; + if (nanoStr.length < 9) { + nanoStr = ZERO_PAD.substring(0, 9 - nanoStr.length) + nanoStr; + } + + return "" + seconds + nanoStr; +} + +/** + * Converts the number of milliseconds since the unix epoch into the OTLP `timeUnixNano` + * representation. + * @param epochMillis - Milliseconds since the unix epoch, may include a fractional component. + * @returns The number of nanoseconds since the unix epoch as a decimal string. + */ +export function epochMillisToUnixNanoStr(epochMillis: number): string { + if (!isNumber(epochMillis) || isNaN(epochMillis) || !isFinite(epochMillis)) { + return "0"; + } + + return hrTimeToUnixNanoStr(millisToHrTime(epochMillis)); +} + +/** + * Resolves the supplied value into the number of milliseconds since the unix epoch. + * @param value - A `Date`, a number of milliseconds, an ISO 8601 date string or a high resolution time. + * @returns The number of milliseconds since the unix epoch or `null` when the value is not a usable time. + */ +export function toEpochMillis(value: Date | number | string | IOTelHrTime): number | null { + if (isNullOrUndefined(value)) { + return null; + } + + if (isNumber(value)) { + return isNaN(value) || !isFinite(value) ? null : value; + } + + if (isString(value)) { + let parsed = Date.parse(value); + return isNaN(parsed) ? null : parsed; + } + + // A Date instance + if ((value as Date).getTime) { + let time = (value as Date).getTime(); + return isNaN(time) ? null : time; + } + + // A high resolution [seconds, nanos] tuple. The result is only used where millisecond precision + // is sufficient, absolute nanosecond values must go through hrTimeToUnixNanoStr instead. + let hrTime = value as IOTelHrTime; + if (isNumber(hrTime[0])) { + return (hrTime[0] * MILLIS_PER_SECOND) + ((hrTime[1] || 0) / NANOS_PER_MILLI); + } + + return null; +} + +/** + * Adds a duration expressed in milliseconds to an absolute time expressed as a `timeUnixNano` + * decimal string, returning a new `timeUnixNano` decimal string. + * + * @remarks + * The addition is performed on the (safe) seconds and nanoseconds components rather than on the + * combined nanosecond value, for the precision reasons described on {@link hrTimeToUnixNanoStr}. + * + * @param startUnixNano - The absolute start time as a decimal string of nanoseconds since the epoch. + * @param durationMs - The duration to add, in milliseconds. + * @returns The resulting absolute time as a decimal string of nanoseconds since the epoch. + */ +export function addMillisToUnixNanoStr(startUnixNano: string, durationMs: number): string { + if (!startUnixNano) { + return "0"; + } + + if (!isNumber(durationMs) || isNaN(durationMs) || !isFinite(durationMs) || durationMs === 0) { + return startUnixNano; + } + + // Split the decimal string back into its (safe) seconds / nanoseconds components. Any value + // shorter than 10 characters is entirely within the nanoseconds component. + let len = startUnixNano.length; + let seconds = len > 9 ? +startUnixNano.substring(0, len - 9) : 0; + let nanos = +startUnixNano.substring(len > 9 ? len - 9 : 0); + + let durationHr = millisToHrTime(durationMs); + + return hrTimeToUnixNanoStr([seconds + durationHr[0], nanos + durationHr[1]] as IOTelHrTime); +} + +/** + * Parses an Application Insights duration value into a number of milliseconds. Durations are + * normally supplied as a number of milliseconds but the envelope format also permits the + * `d.hh:mm:ss.fffffff` timespan representation. + * @param value - The duration to parse. + * @returns The duration in milliseconds, or `0` when the value cannot be parsed. + */ +export function parseDurationMs(value: any): number { + if (isNullOrUndefined(value)) { + return 0; + } + + if (isNumber(value)) { + return isNaN(value) || !isFinite(value) ? 0 : value; + } + + if (!isString(value)) { + return 0; + } + + // d.hh:mm:ss.fffffff (the days component and the fractional seconds are both optional) + let matches = /^(?:(\d+)\.)?(\d+):(\d+):(\d+)(?:\.(\d+))?$/.exec(value); + if (!matches) { + let parsed = +value; + return isNaN(parsed) ? 0 : parsed; + } + + let days = +(matches[1] || 0); + let hours = +matches[2]; + let minutes = +matches[3]; + let seconds = +matches[4]; + + // The fractional component may have up to 7 digits (100ns ticks), normalize it to milliseconds + let fraction = matches[5] || ""; + let millis = 0; + if (fraction) { + millis = +((fraction + "000").substring(0, 3)); + } + + return ((((days * 24 + hours) * 60 + minutes) * 60 + seconds) * MILLIS_PER_SECOND) + millis; +} diff --git a/channels/otlp-channel-js/tsconfig.json b/channels/otlp-channel-js/tsconfig.json new file mode 100644 index 000000000..24d8845c6 --- /dev/null +++ b/channels/otlp-channel-js/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "sourceMap": true, + "inlineSources": true, + "noImplicitAny": true, + "module": "es6", + "moduleResolution": "Node", + "target": "es5", + "alwaysStrict": true, + "suppressImplicitAnyIndexErrors": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": true, + "noEmitHelpers": true, + "skipLibCheck": true, + "declaration": true, + "declarationDir": "build/types", + "outDir": "dist-es5", + "rootDir": "./src", + "removeComments": false, + "lib": [ + "es5", + "dom", + "es2015.iterable", + "es2015.symbol" + ] + }, + "include": [ + "./src/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/channels/otlp-channel-js/tsdoc.json b/channels/otlp-channel-js/tsdoc.json new file mode 100644 index 000000000..03adf8152 --- /dev/null +++ b/channels/otlp-channel-js/tsdoc.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "extends": ["../../tsdoc.json"] + } + \ No newline at end of file diff --git a/channels/otlp-channel-js/typedoc.json b/channels/otlp-channel-js/typedoc.json new file mode 100644 index 000000000..cd31d780f --- /dev/null +++ b/channels/otlp-channel-js/typedoc.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ "./src/applicationinsights-otlpchannel-js.ts" ], + "exclude": [ "**/internal/**/*.ts", "node_modules/**" ], + "externalPattern": [ + "**/node_modules/**", + "node_modules/**" + ], + "sort": [ + "alphabetical", + "kind", + "instance-first" + ], + "basePath": "./src", + "sourceLinkTemplate": "https://github.com/microsoft/ApplicationInsights-JS/blob/main/{path}#L{line}", + "cleanOutputDir": true, + "excludeExternals": false, + "excludeInternal": true, + "excludePrivate": true, + "includeVersion": true, + "groupOrder": [ + "Entrypoint", + "Modules", + "Namespaces", + "Enumerations", + "Enumeration Members", + "Classes", + "Interfaces", + "Type Aliases", + "Constructors", + "Properties", + "Variables", + "Functions", + "Accessors", + "Methods", + "References", + "*" + ], + "tsconfig": "./tsconfig.json", + "out": "../../docs/webSdk/applicationinsights-otlpchannel-js", + "readme": "none", + "githubPages": true, + "gitRevision": "main", + "compilerOptions": { + "stripInternal": true + }, + "sidebarLinks": { + "Changelog": "https://github.com/microsoft/ApplicationInsights-JS/blob/main/RELEASES.md", + "Examples": "https://github.com/microsoft/ApplicationInsights-JS/blob/main/examples/README.md", + "Readme": "https://github.com/microsoft/ApplicationInsights-JS/tree/main/channels/otlp-channel-js" + }, + "navigationLinks": { + "GitHub": "https://github.com/Microsoft/ApplicationInsights-JS", + "npm": "https://www.npmjs.com/package/@microsoft/applicationinsights-otlpchannel-js" + }, + "visibilityFilters": { + "protected": false, + "private": false, + "inherited": true, + "external": true + } +} \ No newline at end of file diff --git a/examples/otlp/README.md b/examples/otlp/README.md new file mode 100644 index 000000000..13f7d3e85 --- /dev/null +++ b/examples/otlp/README.md @@ -0,0 +1,291 @@ +# OTLP Channel Test Site + +A multi page, multi instance test harness for +`@microsoft/applicationinsights-otlpchannel-js`. + +Three pages each run **two completely independent Application Insights instances**, both exporting +through their own OTLP channel to a local mock collector. It verifies that + +- every kind of telemetry the SDK produces converts to **well formed OTLP**, +- the exported records carry all the **expected attributes**, +- the two instances **do not clobber each other's globals**, configuration or telemetry. + +## Prerequisites + +The example bundles straight from the built `dist-es5` output of the workspace packages, so the SDK +must be built first: + +```bash +# from the repository root +npm install # or: node common/scripts/install-run-rush.js update +node common/scripts/install-run-rush.js rebuild +``` + +## Manual run + +```bash +cd examples/otlp +npm run build +npm run serve +``` + +Then open . + +On every page: + +1. The **Instance isolation** checklist renders as soon as the page loads. All seven checks must say + `PASS` — they compare the two instances' core, channel, configuration object, instrumentation + key and `service.name` by identity. +2. Press **Generate telemetry**. This produces, *on each of the two instances*, a page view, a custom + event, five traces (one per severity), an exception, a metric, a manual dependency, a real + `fetch`, a real `XMLHttpRequest`, a deliberately failing request, and a real OpenTelemetry span + created through `startSpan()`. +3. Press **Validate collected** to run the full rule set over everything the collector has received. + The report shows the pass/fail counts and the details of any failure. +4. Visit **Products** and **Checkout** and repeat, then validate again to confirm telemetry from all + three pages is correct. +5. On **Checkout**, press **Unload first instance only** and then **Generate telemetry** again. Only + the surviving instance may continue to export. + +Useful links, also available from the page navigation: + +| Endpoint | Purpose | +| --- | --- | +| `/__collected` | Every OTLP request the collector received, including the raw body | +| `/__validate` | Runs the validation rules and returns the report | +| `POST /__reset` | Clears the collected data | + +## Automated run + +The same scenario driven by a real browser through Puppeteer: + +```bash +cd examples/otlp +npm run build +npm test +``` + +It starts the collector, visits all three pages, validates every payload, and finally verifies that +unloading one instance leaves the other one exporting. It exits non zero on any failure, so it is +suitable for CI. + +``` + visited index.html - generated 24 items and 2 span(s) + visited products.html - generated 24 items and 2 span(s) + visited checkout.html - generated 24 items and 2 span(s) + +OTLP requests received : 12 +Spans exported : 36 +Log records exported : 54 +Services seen : {"storefront-web":6,"checkout-widget":6} +Span kinds : {"1":12,"3":24} +Telemetry types : {"EventData":6,"MessageData":30,"ExceptionData":6,"MetricData":6,"PageviewPerformanceData":6} +Payload assertions : 7062 passed, 0 failed + +After unloading the first instance, services still exporting: ["checkout-widget"] + +PASSED - all OTLP payloads are valid and the instances stayed isolated. +``` + +Add `--headful` to watch the browser: `node tools/automated-test.js --headful`. + +## Validating against a REAL OpenTelemetry Collector + +The mock collector and the validator only check that a payload is *well formed* against rules written +by hand from the specification. To prove the output is genuinely valid OTLP, run it through a real +collector, which parses the payload with the reference implementation and **rejects anything +malformed with a 4xx** instead of quietly accepting it. + +### Get a collector + +Either use Docker: + +```bash +cd examples/otlp/collector +docker compose up +``` + +or download the standalone binary (no Docker required): + +```powershell +cd examples\otlp\collector +.\get-collector.ps1 # or ./get-collector.sh on macOS / Linux +.\bin\otelcol.exe --config otel-collector-config.yaml +``` + +The collector listens for OTLP/HTTP on **4318**, prints everything it parsed via the `debug` +exporter, and re-exports its own canonical OTLP/JSON back to the example's mock collector on 8099. + +### Point the site at it + +Add `?collector=http://localhost:4318` to any page: + + + +The activity log shows which endpoint is in use. Press **Generate telemetry** and watch the collector +console: every span and log record it successfully parsed is printed. Then press **Validate +collected** to validate the collector's own re-serialization of that data. + +### Or run the whole thing automatically + +```bash +npm run test:collector +``` + +This starts the collector and the example server, drives all three pages through a real browser, +verifies the collector never rejected a request, and finally validates the collector's canonical +re-serialization: + +``` +=== Validation of the REAL collector's own re-serialization === +Round tripped requests : 6 +Spans : 36 +Log records : 54 +Services : {"checkout-widget":6,"storefront-web":6} +Span kinds : {"1":12,"3":24} +Assertions : 7248 passed, 0 failed + +=== What the collector itself parsed (debug exporter) === + Span #2 + Name : GET http://localhost:8099/api/products + Kind : Client + Status code : Ok + -> http.request.method: Str(GET) + -> url.full: Str(http://localhost:8099/api/products) + -> server.address: Str(localhost) + -> server.port: Int(8099) + +PASSED - a real OpenTelemetry Collector accepted every payload, and its own + re-serialization of the data satisfies every validation rule. +``` + +Use `--external` if you are already running a collector yourself: + +```bash +node tools/verify-with-collector.js --external +``` + +> The collector binary is large (~190 MB extracted) and is git ignored via `collector/.gitignore`. + +## Seeing exactly what is sent *into* the collector + +Posting straight to the collector on 4318 hides the payload — you only see what the collector +decided to do with it. The example server therefore provides a **tap** that sits in the middle: + +``` +browser -> /tap/v1/traces (records the exact bytes) -> real collector :4318 + | | + +------------- relays the collector's real response back +``` + +Point a page at the tap instead of at the collector: + + + +Then open the **Inspector**: + + + +It lists every request the SDK sent, and for each one shows + +- the HTTP status the **real collector** returned (green when accepted, red when rejected), +- the collector's response body, including its error message when it rejects something, +- the exact payload that was sent, pretty printed, +- the byte size, record count and timestamp. + +Because the tap relays the collector's genuine response, a rejection is seen by the SDK exactly as it +would be without the tap. Sending a deliberately invalid span, for example, shows: + +``` +400 {"code":3,"message":"ID.UnmarshalJSONIter: length mismatch ..."} +``` + +Buttons on the inspector: + +| Button | Effect | +| --- | --- | +| Refresh | Reload the captured list (auto refresh is on by default) | +| Validate what was sent | Runs the full rule set over the payloads **as sent**, before the collector touched them | +| Reset | Clears the capture | + +Endpoints behind it, if you prefer curl: + +| Endpoint | Purpose | +| --- | --- | +| `GET /__tapped` | Everything sent to the collector plus the collector's response to each | +| `GET /__tap-validate` | Validates what was sent, and reports anything the collector rejected | + +Set a different collector with the `OTLP_COLLECTOR_URL` environment variable before starting the +server (it defaults to `http://localhost:4318`). + +## What is validated + +`tools/validate.js` holds the rules and is shared by the collector and the automated test. + +**Envelope** — the body uses only `resourceSpans` / `resourceLogs`; the request went to the +matching signal endpoint; the content type is `application/json`. + +**Resource** — declares `service.name`, `telemetry.sdk.name`, `telemetry.sdk.language` +(`webjs`) and `telemetry.sdk.version`, and does **not** leak the instrumentation key by default. + +**Spans** — `traceId` is 32 lowercase hex characters, `spanId` is 16, `parentSpanId` (when +present) is valid and never equal to the span's own id, `kind` is a valid `SpanKind`, `status.code` +is a valid `StatusCode`, and the span ends at or after it starts. + +**Log records** — `timeUnixNano` and `observedTimeUnixNano` are present, `severityNumber` is one +of the known values and `severityText` is set. + +**Timestamps** — every `timeUnixNano` is a *string* of digits, exactly 19 long, and within a day +of now. The 19 digit assertion is what catches the precision loss that occurs if a nanosecond +timestamp is ever computed with JavaScript number arithmetic. + +**Attributes** — every `AnyValue` sets at most one member and uses a known member name, +`intValue` is a string, `doubleValue` is a finite number, and **no attribute key is ever repeated** +within a record. + +**Coverage** — both services are present, at least one `INTERNAL` and one `CLIENT` span were +exported, at least one span carries `http.request.method` and `url.full`, at least one span reports +`OK` and at least one reports `ERROR`, and log records were exported for `MessageData`, +`ExceptionData`, `EventData` and `MetricData`. + +**Isolation** — each instance uses a distinct core, channel and configuration object; a record +tagged with one instance's marker never appears under the other instance's resource; each service +only ever carries telemetry from a single instance; and unloading one instance stops only that +instance. + +## Layout + +``` +examples/otlp/ + src/otlp-example.js the two SDK instances and the telemetry generators + public/ the three pages, shared page glue and styles + tools/server.js static server + mock OTLP collector + tools/validate.js the validation rules (shared) + tools/automated-test.js the Puppeteer driven end to end test + tools/manual-check.js verifies the button driven manual flow still works + tools/diagnose.js scans every span for missing ids / duplicate keys + tools/dump-samples.js prints one full example of each span and log shape + tools/verify-with-collector.js + validates against a REAL OpenTelemetry Collector + collector/ collector config, docker-compose and download scripts +``` + +## Inspecting the output by hand + +Structural validation only proves a payload is *well formed*, not that it is *semantically right*. +`tools/dump-samples.js` prints the raw request body plus one complete example of each shape (an +OTel `startSpan()` span, an auto collected dependency, a failed dependency, a page view span, an +exception log record and the shared resource), which is how the semantic convention problems listed +in the channel's design notes were found: + +```bash +node tools/dump-samples.js +``` + +## Notes + +- Both instances point the built in Application Insights sender at `/breeze/v2/track` on the mock + collector, so nothing ever reaches the real ingestion endpoint. This also means the example + exercises the OTLP channel **coexisting** with the standard sender in the same channel queue. +- `/api/missing` returns 404 on purpose, to produce an unsuccessful dependency span. +- The collector keeps everything in memory; restart it (or `POST /__reset`) to start over. diff --git a/examples/otlp/collector/.gitignore b/examples/otlp/collector/.gitignore new file mode 100644 index 000000000..c3666a41a --- /dev/null +++ b/examples/otlp/collector/.gitignore @@ -0,0 +1,2 @@ +# Ignore the downloaded collector binary +bin/ diff --git a/examples/otlp/collector/docker-compose.yml b/examples/otlp/collector/docker-compose.yml new file mode 100644 index 000000000..6377d0edc --- /dev/null +++ b/examples/otlp/collector/docker-compose.yml @@ -0,0 +1,17 @@ +# Runs a real OpenTelemetry Collector for validating the OTLP channel output. +# +# docker compose up +# +# The collector listens for OTLP/HTTP on 4318 and re-exports what it parsed back to the example's +# mock collector on the host (port 8099). +services: + otel-collector: + image: otel/opentelemetry-collector:0.157.0 + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./otel-collector-config.docker.yaml:/etc/otelcol/config.yaml:ro + ports: + - "4318:4318" + extra_hosts: + # Allows the collector inside the container to reach the example server on the host + - "host.docker.internal:host-gateway" diff --git a/examples/otlp/collector/get-collector.ps1 b/examples/otlp/collector/get-collector.ps1 new file mode 100644 index 000000000..fd626483c --- /dev/null +++ b/examples/otlp/collector/get-collector.ps1 @@ -0,0 +1,39 @@ +# Downloads the OpenTelemetry Collector binary used to validate the OTLP channel output. +# +# .\get-collector.ps1 +# +# Use this when Docker is not available; otherwise `docker compose up` in this folder is simpler. + +param( + [string] $Version = "0.157.0" +) + +$ErrorActionPreference = "Stop" + +$binDir = Join-Path $PSScriptRoot "bin" +$exe = Join-Path $binDir "otelcol.exe" + +if (Test-Path $exe) { + Write-Host "The collector is already present at $exe" + & $exe --version + exit 0 +} + +New-Item -ItemType Directory -Force -Path $binDir | Out-Null + +$arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" } +$asset = "otelcol_${Version}_windows_${arch}.tar.gz" +$url = "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v$Version/$asset" +$archive = Join-Path $binDir $asset + +Write-Host "Downloading $url ..." +Invoke-WebRequest -Uri $url -OutFile $archive -UseBasicParsing -TimeoutSec 600 + +Write-Host "Extracting ..." +tar -xzf $archive -C $binDir +Remove-Item $archive -ErrorAction SilentlyContinue + +& $exe --version +Write-Host "" +Write-Host "Run it with:" +Write-Host " .\bin\otelcol.exe --config otel-collector-config.yaml" diff --git a/examples/otlp/collector/get-collector.sh b/examples/otlp/collector/get-collector.sh new file mode 100644 index 000000000..f4321cdbe --- /dev/null +++ b/examples/otlp/collector/get-collector.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Downloads the OpenTelemetry Collector binary used to validate the OTLP channel output. +# +# ./get-collector.sh +# +# Use this when Docker is not available; otherwise `docker compose up` in this folder is simpler. +set -euo pipefail + +VERSION="${1:-0.157.0}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="$SCRIPT_DIR/bin" +EXE="$BIN_DIR/otelcol" + +if [ -x "$EXE" ]; then + echo "The collector is already present at $EXE" + "$EXE" --version + exit 0 +fi + +mkdir -p "$BIN_DIR" + +case "$(uname -s)" in + Linux*) OS=linux ;; + Darwin*) OS=darwin ;; + *) echo "Unsupported platform: $(uname -s). Use docker compose instead." >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) ARCH=amd64 ;; + arm64|aarch64) ARCH=arm64 ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +ASSET="otelcol_${VERSION}_${OS}_${ARCH}.tar.gz" +URL="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${VERSION}/${ASSET}" + +echo "Downloading $URL ..." +curl -fsSL "$URL" -o "$BIN_DIR/$ASSET" + +echo "Extracting ..." +tar -xzf "$BIN_DIR/$ASSET" -C "$BIN_DIR" +rm -f "$BIN_DIR/$ASSET" + +"$EXE" --version +echo +echo "Run it with:" +echo " ./bin/otelcol --config otel-collector-config.yaml" diff --git a/examples/otlp/collector/otel-collector-config.docker.yaml b/examples/otlp/collector/otel-collector-config.docker.yaml new file mode 100644 index 000000000..23162fc7f --- /dev/null +++ b/examples/otlp/collector/otel-collector-config.docker.yaml @@ -0,0 +1,59 @@ +# OpenTelemetry Collector configuration for validating the Application Insights OTLP channel. +# +# The browser posts OTLP/JSON straight to this collector. Because the collector parses the payload +# with the real protocol implementation, anything malformed is rejected with a 400 rather than being +# quietly accepted -- which is the point of running it. +# +# What the collector receives is then: +# 1. printed to the console by the debug exporter, and +# 2. re-exported as canonical OTLP/JSON back to the example's mock collector on port 8099, so the +# example's validator can assert on the collector's own re-serialization of the data. + +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + # The example site is served from a different origin, so the browser needs CORS + cors: + allowed_origins: + - http://localhost:8099 + - http://127.0.0.1:8099 + - http://localhost:8098 + - http://localhost:8097 + - http://localhost:8096 + allowed_headers: + - "*" + +processors: + batch: + timeout: 1s + +exporters: + # Prints every span and log record the collector successfully parsed + debug: + verbosity: detailed + + # Sends the collector's own canonical re-serialization back to the example's mock collector, where + # `/__validate` can be run over it + otlphttp/roundtrip: + endpoint: http://host.docker.internal:8099 + encoding: json + # The example's mock collector reads the body as text, so it must not be compressed + compression: none + tls: + insecure: true + +service: + telemetry: + logs: + level: info + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlphttp/roundtrip] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlphttp/roundtrip] diff --git a/examples/otlp/collector/otel-collector-config.yaml b/examples/otlp/collector/otel-collector-config.yaml new file mode 100644 index 000000000..6091dd5d9 --- /dev/null +++ b/examples/otlp/collector/otel-collector-config.yaml @@ -0,0 +1,59 @@ +# OpenTelemetry Collector configuration for validating the Application Insights OTLP channel. +# +# The browser posts OTLP/JSON straight to this collector. Because the collector parses the payload +# with the real protocol implementation, anything malformed is rejected with a 400 rather than being +# quietly accepted -- which is the point of running it. +# +# What the collector receives is then: +# 1. printed to the console by the debug exporter, and +# 2. re-exported as canonical OTLP/JSON back to the example's mock collector on port 8099, so the +# example's validator can assert on the collector's own re-serialization of the data. + +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + # The example site is served from a different origin, so the browser needs CORS + cors: + allowed_origins: + - http://localhost:8099 + - http://127.0.0.1:8099 + - http://localhost:8098 + - http://localhost:8097 + - http://localhost:8096 + allowed_headers: + - "*" + +processors: + batch: + timeout: 1s + +exporters: + # Prints every span and log record the collector successfully parsed + debug: + verbosity: detailed + + # Sends the collector's own canonical re-serialization back to the example's mock collector, where + # `/__validate` can be run over it + otlphttp/roundtrip: + endpoint: http://localhost:8099 + encoding: json + # The example's mock collector reads the body as text, so it must not be compressed + compression: none + tls: + insecure: true + +service: + telemetry: + logs: + level: info + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlphttp/roundtrip] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlphttp/roundtrip] diff --git a/examples/otlp/package.json b/examples/otlp/package.json new file mode 100644 index 000000000..1931ee8e2 --- /dev/null +++ b/examples/otlp/package.json @@ -0,0 +1,17 @@ +{ + "name": "@microsoft/applicationinsights-example-otlp", + "version": "0.1.0", + "private": true, + "description": "Multi page / multi instance manual and automated test harness for the Application Insights OTLP channel", + "author": "Microsoft Application Insights Team", + "license": "MIT", + "scripts": { + "build": "rollup -c rollup.config.js --bundleConfigAsCjs", + "serve": "node tools/server.js", + "test": "node tools/automated-test.js", + "test:collector": "node tools/verify-with-collector.js", + "get-collector": "powershell -ExecutionPolicy Bypass -File collector/get-collector.ps1", + "dump": "node tools/dump-samples.js", + "start": "npm run build && npm run serve" + } +} diff --git a/examples/otlp/public/checkout.html b/examples/otlp/public/checkout.html new file mode 100644 index 000000000..19bcf0d72 --- /dev/null +++ b/examples/otlp/public/checkout.html @@ -0,0 +1,51 @@ + + + + + OTLP Channel Example - Checkout + + + +
+

Application Insights OTLP Channel — Checkout

+ +
+ + +
+

+ This page additionally lets you unload only the first instance, to confirm that + tearing one instance down leaves the second instance fully operational. +

+ + + + + + +
+
+

Instance isolation

+
    +

    Activity

    +
    
    +        
    +
    +

    Validation report

    +
    
    +            

    Diagnostics

    +
    
    +        
    +
    + + + + + diff --git a/examples/otlp/public/custom-sku.html b/examples/otlp/public/custom-sku.html new file mode 100644 index 000000000..142af265a --- /dev/null +++ b/examples/otlp/public/custom-sku.html @@ -0,0 +1,143 @@ + + + + + OTLP Channel Example - Custom SKU (OfflineChannel -> OtlpChannel) + + + +
    +

    Custom SKU — OfflineChannel → OtlpChannel

    + +
    + +
    + +

    + A SKU built directly on AppInsightsCore with the real offline channel in + front of the OTLP channel, in a single channel queue. There is no Application Insights sender + and no 1DS post channel here. +

    +

    + The offline channel resolves its online channel by identifier from + primaryOnlineChannelId, which defaults to + [AppInsightsChannelPlugin, PostChannel]. Use the two buttons to compare a SKU + that names the OTLP channel against one that leaves the default in place. +

    + + + + + + +
    +
    +

    Chain checks

    +
      +

      Activity

      +
      
      +        
      +
      +

      Resolved chain

      +
      
      +        
      +
      + + + + + diff --git a/examples/otlp/public/index.html b/examples/otlp/public/index.html new file mode 100644 index 000000000..8d3642a1d --- /dev/null +++ b/examples/otlp/public/index.html @@ -0,0 +1,51 @@ + + + + + OTLP Channel Example - Home + + + +
      +

      Application Insights OTLP Channel — Home

      + +
      + + +
      +

      + Two independent Application Insights instances are running on this page + (storefront-web and checkout-widget), each with its own OTLP channel + exporting to the local mock collector. +

      + + + + + +
      +
      +

      Instance isolation

      +
        +

        Activity

        +
        
        +        
        +
        +

        Validation report

        +
        
        +            

        Diagnostics

        +
        
        +        
        +
        + + + + + diff --git a/examples/otlp/public/inspect.html b/examples/otlp/public/inspect.html new file mode 100644 index 000000000..d0d11108c --- /dev/null +++ b/examples/otlp/public/inspect.html @@ -0,0 +1,211 @@ + + + + + OTLP Inspector - what is being sent to the collector + + + + +
        +

        OTLP Inspector — what the SDK is sending to the collector

        + +
        + +

        + + + + + + +
        + +
        +
        +

        Requests sent to the collector

        +
        +

        Validation of what was sent

        +
        
        +        
        +
        +

        Collector response

        +
        
        +            

        Exact payload sent (pretty printed)

        +
        
        +        
        +
        + + + + diff --git a/examples/otlp/public/page.js b/examples/otlp/public/page.js new file mode 100644 index 000000000..08cdd5041 --- /dev/null +++ b/examples/otlp/public/page.js @@ -0,0 +1,166 @@ +/* Shared page glue for the OTLP example site. */ +(function () { + "use strict"; + + var pageName = document.body.getAttribute("data-page") || "unknown"; + + function el(id) { + return document.getElementById(id); + } + + function log(message) { + var output = el("log"); + if (output) { + output.textContent += message + "\n"; + output.scrollTop = output.scrollHeight; + } + } + + function show(id, value) { + var target = el(id); + if (target) { + target.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2); + } + } + + function renderDiagnostics(diagnostics) { + show("diagnostics", diagnostics); + + var isolation = el("isolation"); + if (!isolation || !diagnostics.instances || diagnostics.instances.length < 2) { + return; + } + + var a = diagnostics.instances[0]; + var b = diagnostics.instances[1]; + var checks = [ + ["Each instance has its own core", a.coreId !== b.coreId], + ["Each instance has its own OTLP channel", a.channelId !== b.channelId], + ["Each instance has its own config object", a.configId !== b.configId], + ["Each instance kept its own instrumentation key", a.iKey !== b.iKey && !!a.iKey && !!b.iKey], + ["Each instance kept its own service.name", + a.resourceServiceName !== b.resourceServiceName && !!a.resourceServiceName], + ["Both instances are initialized", a.isInitialized && b.isInitialized], + ["No initialization errors", (diagnostics.errors || []).length === 0] + ]; + + isolation.innerHTML = ""; + checks.forEach(function (check) { + var li = document.createElement("li"); + li.className = check[1] ? "pass" : "fail"; + li.textContent = (check[1] ? "PASS " : "FAIL ") + check[0]; + isolation.appendChild(li); + }); + } + + function renderEndpointBanner() { + var banner = el("endpoint"); + if (!banner) { + return; + } + + var usingTap = otlpExample.isUsingTap(); + banner.className = "endpoint " + (usingTap ? "via-tap" : "via-mock"); + banner.innerHTML = ""; + + var label = document.createElement("span"); + label.innerHTML = usingTap + ? "Exporting through the tap → real OpenTelemetry Collector. Requests appear in the Inspector." + : "Exporting to the built in mock collector. Nothing will appear in the Inspector."; + banner.appendChild(label); + + var code = document.createElement("code"); + code.textContent = otlpExample.getEndpoint(); + banner.appendChild(code); + + var toggle = document.createElement("button"); + toggle.textContent = usingTap ? "Switch to mock collector" : "Switch to real collector (tap)"; + toggle.addEventListener("click", function () { + otlpExample.setEndpoint(usingTap ? null : otlpExample.getTapEndpoint()); + }); + banner.appendChild(toggle); + } + + /** + * The navigation links are plain paths, so without this the `?collector=` choice would silently + * be lost as soon as another page was opened. The endpoint is also persisted, this simply keeps + * the address bar honest about what is in effect. + */ + function preserveEndpointOnLinks() { + if (!otlpExample.isUsingTap()) { + return; + } + + var query = "?collector=" + encodeURIComponent(otlpExample.getEndpoint()); + var links = document.querySelectorAll("nav a"); + + for (var lp = 0; lp < links.length; lp++) { + var href = links[lp].getAttribute("href"); + if (href && href.indexOf(".html") !== -1 && href.indexOf("collector=") === -1) { + links[lp].setAttribute("href", href + query); + } + } + } + + window.addEventListener("load", function () { + log("Initializing two Application Insights instances for page '" + pageName + "'..."); + + var diagnostics = otlpExample.init(pageName); + renderDiagnostics(diagnostics); + log("Initialized. Globals: " + diagnostics.globals.names.join(", ")); + log("Exporting OTLP to: " + otlpExample.getEndpoint()); + + renderEndpointBanner(); + preserveEndpointOnLinks(); + + if (diagnostics.errors.length) { + log("ERRORS: " + diagnostics.errors.join(" | ")); + } + + var autoRun = window.location.search.indexOf("autorun") !== -1; + + el("generate").addEventListener("click", function () { + log("Generating telemetry..."); + otlpExample.runPage(pageName).then(function (summary) { + log("Generated " + summary.generated + " items and " + summary.spans + " span(s), then flushed."); + renderDiagnostics(summary.diagnostics); + }); + }); + + el("validate").addEventListener("click", function () { + fetch("/__validate").then(function (r) { + return r.json(); + }).then(function (report) { + show("validation", report); + log("Validation: " + (report.ok ? "PASS" : "FAIL") + " (" + report.passedCount + + " passed, " + report.failedCount + " failed)"); + }); + }); + + el("reset").addEventListener("click", function () { + fetch("/__reset", { method: "POST" }).then(function () { + log("Collector reset."); + show("validation", ""); + }); + }); + + var unloadBtn = el("unloadFirst"); + if (unloadBtn) { + unloadBtn.addEventListener("click", function () { + log("Unloading the first instance only..."); + var updated = otlpExample.unloadFirst(); + renderDiagnostics(updated); + log("First instance unloaded. The second instance must still work - press Generate again."); + }); + } + + if (autoRun) { + log("autorun requested"); + otlpExample.runPage(pageName).then(function (summary) { + log("autorun complete: " + summary.generated + " items, " + summary.spans + " span(s)"); + renderDiagnostics(summary.diagnostics); + window.__otlpAutoRunComplete = true; + }); + } + }); +})(); diff --git a/examples/otlp/public/products.html b/examples/otlp/public/products.html new file mode 100644 index 000000000..adfb0c6d4 --- /dev/null +++ b/examples/otlp/public/products.html @@ -0,0 +1,50 @@ + + + + + OTLP Channel Example - Products + + + +
        +

        Application Insights OTLP Channel — Products

        + +
        + + +
        +

        + A second page, so that page views, page level resource attributes and the per page telemetry + of both instances can be compared across navigations. +

        + + + + + +
        +
        +

        Instance isolation

        +
          +

          Activity

          +
          
          +        
          +
          +

          Validation report

          +
          
          +            

          Diagnostics

          +
          
          +        
          +
          + + + + + diff --git a/examples/otlp/public/site.css b/examples/otlp/public/site.css new file mode 100644 index 000000000..722c887d3 --- /dev/null +++ b/examples/otlp/public/site.css @@ -0,0 +1,127 @@ +body { + font-family: "Segoe UI", system-ui, sans-serif; + margin: 0; + padding: 0 24px 48px; + color: #1b1b1b; + background: #fafafa; +} + +header { + padding: 16px 0; + border-bottom: 1px solid #ddd; + margin-bottom: 16px; +} + +h1 { + font-size: 20px; + margin: 0 0 8px; +} + +nav a { + margin-right: 12px; + color: #0067b8; +} + +nav a.current { + font-weight: 600; + text-decoration: none; + color: #1b1b1b; +} + +button { + font: inherit; + padding: 6px 14px; + margin-right: 8px; + border: 1px solid #8a8886; + border-radius: 2px; + background: #fff; + cursor: pointer; +} + +button:hover { + background: #f3f2f1; +} + +.cols { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 16px; +} + +.col { + flex: 1 1 380px; + min-width: 320px; +} + +h2 { + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #605e5c; + margin: 16px 0 6px; +} + +pre { + background: #fff; + border: 1px solid #e1dfdd; + padding: 10px; + max-height: 320px; + overflow: auto; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +ul { + list-style: none; + padding: 0; + margin: 0; + font-size: 13px; +} + +li { + padding: 3px 0; + font-family: Consolas, monospace; +} + +li.pass { + color: #107c10; +} + +li.fail { + color: #a4262c; + font-weight: 600; +} + +.endpoint { + padding: 10px 14px; + margin: 0 0 14px; + border-left: 4px solid #8a8886; + background: #fff; + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.endpoint code { + background: #f3f2f1; + padding: 2px 6px; + font-size: 12px; +} + +.endpoint button { + margin: 0; +} + +.endpoint.via-tap { + border-left-color: #107c10; + background: #f3f9f1; +} + +.endpoint.via-mock { + border-left-color: #ca5010; + background: #fdf6f3; +} diff --git a/examples/otlp/rollup.config.js b/examples/otlp/rollup.config.js new file mode 100644 index 000000000..2a019e62f --- /dev/null +++ b/examples/otlp/rollup.config.js @@ -0,0 +1,70 @@ +const path = require("path"); +const fs = require("fs"); +const nodeResolve = require("@rollup/plugin-node-resolve").nodeResolve; +const commonjs = require("@rollup/plugin-commonjs"); + +const repoRoot = path.resolve(__dirname, "../.."); + +/** + * The workspace packages that make up the SDK. The example is bundled straight from the built + * `dist-es5` output of each package rather than from `node_modules`, so that it always exercises the + * code currently in the repo (including the OTLP channel being tested). + */ +const workspacePackages = [ + "shared/AppInsightsCore", + "shared/AppInsightsCommon", + "extensions/applicationinsights-analytics-js", + "extensions/applicationinsights-properties-js", + "extensions/applicationinsights-dependencies-js", + "extensions/applicationinsights-cfgsync-js", + "channels/applicationinsights-channel-js", + "channels/offline-channel-js", + "channels/otlp-channel-js", + "AISKU" +]; + +function buildPackageMap() { + const map = {}; + workspacePackages.forEach((pkgDir) => { + const pkgPath = path.join(repoRoot, pkgDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + const entry = path.join(repoRoot, pkgDir, pkg.module); + if (!fs.existsSync(entry)) { + throw new Error("The '" + pkg.name + "' package has not been built, expected " + entry + + ". Build the SDK before building this example."); + } + + map[pkg.name] = entry; + }); + + return map; +} + +const packageMap = buildPackageMap(); + +/** + * Resolves the `@microsoft/applicationinsights-*` imports to the built workspace output. + */ +function workspaceResolver() { + return { + name: "workspace-resolver", + resolveId(source) { + return packageMap[source] || null; + } + }; +} + +module.exports = { + input: "src/otlp-example.js", + output: { + file: "public/dist/otlp-example.js", + format: "iife", + name: "otlpExample", + sourcemap: true + }, + plugins: [ + workspaceResolver(), + nodeResolve({ browser: true, preferBuiltins: false }), + commonjs() + ] +}; diff --git a/examples/otlp/src/custom-sku.js b/examples/otlp/src/custom-sku.js new file mode 100644 index 000000000..4f814019b --- /dev/null +++ b/examples/otlp/src/custom-sku.js @@ -0,0 +1,207 @@ +/* + * A "custom SKU" built directly on AppInsightsCore, wiring the real OfflineChannel in front of the + * OTLP channel in a single channel queue: + * + * OfflineChannel (priority 1000) -> OtlpChannel (priority 1021) + * + * This exists to answer a specific question: does the OTLP channel still work when a customer places + * it behind other channels? + * + * The important subtlety is that the offline channel resolves its "online" channel by identifier + * from `primaryOnlineChannelId`, which defaults to the Application Insights sender and the 1DS post + * channel. A SKU that has neither must name the OTLP channel explicitly, otherwise the offline + * channel finds no online channel and silently stores nothing. + */ +import { AppInsightsCore } from "@microsoft/applicationinsights-core-js"; +import { OfflineChannel } from "@microsoft/applicationinsights-offlinechannel-js"; +import { OtlpChannel } from "@microsoft/applicationinsights-otlpchannel-js"; + +var _state = null; + +function _resolveEndpoint() { + var match = /[?&]collector=([^&]+)/.exec(window.location.search); + if (match) { + return decodeURIComponent(match[1]).replace(/\/+$/, ""); + } + + try { + var stored = window.localStorage.getItem("otlpExample.collector"); + if (stored) { + return stored.replace(/\/+$/, ""); + } + } catch (e) { + // ignore + } + + return window.location.origin; +} + +/** + * Builds the custom SKU. + * @param nameOnlineChannel - When true, the offline channel is told that the OTLP channel is its + * online channel. When false the default is left in place, which demonstrates the misconfiguration. + */ +export function initSku(nameOnlineChannel) { + var endpoint = _resolveEndpoint(); + var core = new AppInsightsCore(); + var offlineChannel = new OfflineChannel(); + var otlpChannel = new OtlpChannel(); + + var extensionConfig = {}; + extensionConfig[otlpChannel.identifier] = { + endpointUrl: endpoint, + maxBatchInterval: 2000, + maxRecordsPerBatch: 50, + metricsAsLogs: true, + resourceAttributes: { + "service.name": "custom-sku", + "test.instance.marker": "custom-sku", + "test.page": "custom-sku" + } + }; + + extensionConfig[offlineChannel.identifier] = nameOnlineChannel + ? { primaryOnlineChannelId: [otlpChannel.identifier] } + : {}; + + core.initialize({ + instrumentationKey: "33333333-3333-3333-3333-333333333333", + endpointUrl: endpoint, + channels: [[ offlineChannel, otlpChannel ]], + extensionConfig: extensionConfig + }, []); + + _state = { + core: core, + offlineChannel: offlineChannel, + otlpChannel: otlpChannel, + endpoint: endpoint, + namedOnlineChannel: !!nameOnlineChannel + }; + + window.__customSku = _state; + + return getSkuDiagnostics(); +} + +/** + * Reports how the chain actually resolved, which is what needs verifying. + */ +export function getSkuDiagnostics() { + if (!_state) { + return { initialized: false }; + } + + var core = _state.core; + var channels = []; + var coreChannels = core.getChannels() || []; + for (var lp = 0; lp < coreChannels.length; lp++) { + channels.push({ + identifier: coreChannels[lp].identifier, + priority: coreChannels[lp].priority + }); + } + + // The offline channel finds its online channel through core.getPlugin() + var resolved = core.getPlugin(_state.otlpChannel.identifier); + var offlineSupport = null; + try { + var support = _state.otlpChannel.getOfflineSupport(); + offlineSupport = { + url: support.getUrl(), + canSerialize: !!support.serialize({ + name: "probe", + baseType: "MessageData", + baseData: { message: "probe" } + }) + }; + } catch (e) { + offlineSupport = { error: String(e) }; + } + + return { + initialized: true, + endpoint: _state.endpoint, + namedOnlineChannel: _state.namedOnlineChannel, + channels: channels, + otlpIsLast: channels.length > 0 && channels[channels.length - 1].identifier === "OtlpChannel", + otlpResolvableByIdentifier: !!(resolved && resolved.plugin), + offlineSupport: offlineSupport + }; +} + +/** + * Generates telemetry through the custom SKU. + */ +export function generateSku() { + if (!_state) { + return 0; + } + + var core = _state.core; + var count = 0; + + function track(item) { + core.track(item); + count++; + } + + track({ + name: "Microsoft.ApplicationInsights.Message", + iKey: core.config.instrumentationKey, + baseType: "MessageData", + baseData: { message: "custom sku trace", severityLevel: 1, properties: { "test.marker": "custom-sku" } } + }); + + track({ + name: "Microsoft.ApplicationInsights.Event", + iKey: core.config.instrumentationKey, + baseType: "EventData", + baseData: { name: "custom-sku-event", properties: { "test.marker": "custom-sku" } } + }); + + track({ + name: "Microsoft.ApplicationInsights.RemoteDependency", + iKey: core.config.instrumentationKey, + baseType: "RemoteDependencyData", + baseData: { + id: "|4bf92f3577b34da6a3ce929d0e0e4736.00f067aa0ba902b7.", + name: "GET /custom-sku", + target: "https://custom.example.com:8443/api", + type: "Http", + duration: 12, + success: true, + responseCode: 200, + properties: { "test.marker": "custom-sku" } + } + }); + + return count; +} + +/** + * Flushes the custom SKU's OTLP channel. + */ +export function flushSku() { + if (!_state) { + return Promise.resolve(); + } + + _state.otlpChannel.flush(true); + + return new Promise(function (resolve) { + setTimeout(resolve, 600); + }); +} + +/** + * Tears the custom SKU down. + */ +export function unloadSku() { + if (_state && _state.core.isInitialized()) { + _state.otlpChannel.pause(); + _state.core.unload(false); + } + + _state = null; +} diff --git a/examples/otlp/src/otlp-example.js b/examples/otlp/src/otlp-example.js new file mode 100644 index 000000000..d1b90c1ed --- /dev/null +++ b/examples/otlp/src/otlp-example.js @@ -0,0 +1,500 @@ +/* + * Application Insights OTLP channel - multi page / multi instance test harness. + * + * Two completely independent Application Insights instances are created on every page, each with its + * own OTLP channel pointing at the local mock collector but reporting a different `service.name`. + * This exercises: + * + * - that every kind of telemetry the SDK produces is converted to well formed OTLP, + * - that the two instances do not clobber each other's globals or each other's configuration, + * - that telemetry from one instance never appears under the other instance's resource. + */ +import { ApplicationInsights } from "@microsoft/applicationinsights-web"; +import { OtlpChannel } from "@microsoft/applicationinsights-otlpchannel-js"; + +var COLLECTOR = window.location.origin; +var ENDPOINT_STORAGE_KEY = "otlpExample.collector"; + +function _readStoredEndpoint() { + try { + return window.localStorage.getItem(ENDPOINT_STORAGE_KEY); + } catch (e) { + return null; + } +} + +function _storeEndpoint(value) { + try { + if (value) { + window.localStorage.setItem(ENDPOINT_STORAGE_KEY, value); + } else { + window.localStorage.removeItem(ENDPOINT_STORAGE_KEY); + } + } catch (e) { + // Storage is unavailable, the choice simply will not persist across navigations + } +} + +/** + * The OTLP endpoint may be redirected at a real OpenTelemetry Collector using `?collector=`, + * for example `?collector=http://localhost:8099/tap`. + * + * @remarks + * The choice is remembered in local storage so that it survives navigating between the pages (whose + * links would otherwise drop the query string) and so that it is visible to the inspector page in + * another tab. + */ +function _resolveOtlpEndpoint() { + var match = /[?&]collector=([^&]+)/.exec(window.location.search); + if (match) { + var fromQuery = decodeURIComponent(match[1]).replace(/\/+$/, ""); + _storeEndpoint(fromQuery); + return fromQuery; + } + + var stored = _readStoredEndpoint(); + if (stored) { + return stored.replace(/\/+$/, ""); + } + + return COLLECTOR; +} + +var OTLP_ENDPOINT = _resolveOtlpEndpoint(); + +/** + * The two instances under test. Each has a distinct instrumentation key, a distinct service name and + * a distinct marker attribute so that cross contamination between them is detectable. + */ +var INSTANCES = [ + { + id: "storefront", + iKey: "11111111-1111-1111-1111-111111111111", + serviceName: "storefront-web", + marker: "instance-a", + globalName: "aiStorefront" + }, + { + id: "checkout", + iKey: "22222222-2222-2222-2222-222222222222", + serviceName: "checkout-widget", + marker: "instance-b", + globalName: "aiCheckout" + } +]; + +var _instances = {}; +var _errors = []; + +function _recordError(context, err) { + var message = context + ": " + (err && err.message ? err.message : String(err)); + _errors.push(message); + if (window.console && console.error) { + console.error(message); + } +} + +function _createInstance(def, pageName) { + var otlpChannel = new OtlpChannel(); + + var extensionConfig = {}; + extensionConfig[otlpChannel.identifier] = { + endpointUrl: OTLP_ENDPOINT, + // Keep the batches small and the interval short so that the manual page and the automated + // test do not have to wait long to see data. + maxBatchInterval: 2000, + maxRecordsPerBatch: 50, + metricsAsLogs: true, + resourceAttributes: { + "service.name": def.serviceName, + "deployment.environment": "otlp-example", + "test.instance.marker": def.marker, + "test.page": pageName + } + }; + + var appInsights = new ApplicationInsights({ + config: { + instrumentationKey: def.iKey, + // Point the built in Application Insights sender at the mock collector as well, so that + // nothing escapes to the real ingestion endpoint while the two channels coexist. + endpointUrl: COLLECTOR + "/breeze/v2/track", + channels: [[ otlpChannel ]], + extensionConfig: extensionConfig, + disableAjaxTracking: false, + disableFetchTracking: false, + enableAutoRouteTracking: false, + disableExceptionTracking: false, + // Emit an internal message to the console so problems are visible during a manual run + loggingLevelConsole: 1 + } + }); + + appInsights.loadAppInsights(); + + return { + def: def, + appInsights: appInsights, + channel: otlpChannel + }; +} + +/** + * Creates both instances and publishes each one under its own global. + * @param pageName - The logical name of the page being loaded. + */ +export function init(pageName) { + for (var lp = 0; lp < INSTANCES.length; lp++) { + var def = INSTANCES[lp]; + try { + var created = _createInstance(def, pageName); + _instances[def.id] = created; + window[def.globalName] = created.appInsights; + } catch (e) { + _recordError("init(" + def.id + ")", e); + } + } + + window.__otlpInstances = _instances; + + return getDiagnostics(); +} + +/** + * The OTLP endpoint the channels are currently exporting to. + */ +export function getEndpoint() { + return OTLP_ENDPOINT; +} + +/** + * The url of the tap, which records what is sent and forwards it to the real collector. + */ +export function getTapEndpoint() { + return COLLECTOR + "/tap"; +} + +/** + * `true` when the channels are exporting through the tap, and so through the real collector. + */ +export function isUsingTap() { + return OTLP_ENDPOINT === getTapEndpoint(); +} + +/** + * Switches which endpoint the channels export to and reloads so the change takes effect. Passing + * nothing reverts to the built in mock collector. + * @param endpoint - The endpoint to use, or null for the mock collector. + */ +export function setEndpoint(endpoint) { + _storeEndpoint(endpoint || null); + + // Drop any collector query string so the stored value is what takes effect + window.location.href = window.location.pathname; +} + +function _each(callback) { + var results = []; + for (var key in _instances) { + if (Object.prototype.hasOwnProperty.call(_instances, key)) { + try { + results.push(callback(_instances[key], key)); + } catch (e) { + _recordError(key, e); + } + } + } + + return results; +} + +/** + * Generates one of every kind of telemetry, on both instances. + * @param pageName - The logical name of the page generating the telemetry. + */ +export function generateAll(pageName) { + var generated = 0; + + _each(function (inst, key) { + var ai = inst.appInsights; + var marker = inst.def.marker; + + ai.trackPageView({ + name: pageName, + uri: window.location.href, + properties: { "test.marker": marker, "test.page": pageName } + }); + + ai.trackEvent({ + name: "example-event-" + pageName, + properties: { "test.marker": marker, "test.page": pageName, "custom.string": "hello" }, + measurements: { "custom.measurement": 42 } + }); + + ai.trackTrace({ + message: "verbose trace from " + key, + severityLevel: 0, + properties: { "test.marker": marker } + }); + ai.trackTrace({ + message: "information trace from " + key, + severityLevel: 1, + properties: { "test.marker": marker } + }); + ai.trackTrace({ + message: "warning trace from " + key, + severityLevel: 2, + properties: { "test.marker": marker } + }); + ai.trackTrace({ + message: "error trace from " + key, + severityLevel: 3, + properties: { "test.marker": marker } + }); + ai.trackTrace({ + message: "critical trace from " + key, + severityLevel: 4, + properties: { "test.marker": marker } + }); + + ai.trackException({ + exception: new Error("example exception from " + key), + severityLevel: 3, + properties: { "test.marker": marker } + }); + + ai.trackMetric({ + name: "example-metric", + average: 12.5, + sampleCount: 3, + min: 10, + max: 15 + }, { "test.marker": marker }); + + ai.trackDependencyData({ + id: "manual-dep-" + key + "-" + pageName, + name: "GET /api/manual", + responseCode: 200, + duration: 25, + success: true, + type: "Http", + target: "manual.example.com", + data: "https://manual.example.com/api/manual", + properties: { "test.marker": marker } + }); + + generated += 12; + }); + + return generated; +} + +/** + * Issues a real fetch and a real XMLHttpRequest so that the dependency plugin produces automatically + * collected dependency telemetry on both instances. + */ +export function generateDependencies() { + var promises = []; + + promises.push(fetch(COLLECTOR + "/api/products").then(function (response) { + return response.json(); + }).catch(function (e) { + _recordError("fetch", e); + })); + + promises.push(new Promise(function (resolve) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", COLLECTOR + "/api/inventory", true); + xhr.onloadend = function () { + resolve(); + }; + xhr.onerror = function () { + resolve(); + }; + xhr.send(); + })); + + // A deliberately failing request so that an unsuccessful dependency span is produced + promises.push(fetch(COLLECTOR + "/api/missing").catch(function () { + // expected + })); + + return Promise.all(promises); +} + +/** + * Creates a real OpenTelemetry span on each instance using the SDK's tracer, which the SDK converts + * into telemetry and the OTLP channel then converts back into an OTLP span. + */ +export function generateSpans(pageName) { + var created = 0; + + _each(function (inst, key) { + var ai = inst.appInsights; + if (!ai.startSpan) { + return; + } + + var span = ai.startSpan("example-span-" + pageName); + if (span) { + if (span.setAttribute) { + span.setAttribute("test.marker", inst.def.marker); + span.setAttribute("test.page", pageName); + span.setAttribute("custom.span.attribute", "span-value"); + } + + if (span.end) { + span.end(); + } + + created++; + } + }); + + return created; +} + +/** + * Flushes both instances and resolves once the exports have been issued. + */ +export function flushAll() { + _each(function (inst) { + inst.channel.flush(true); + }); + + return new Promise(function (resolve) { + setTimeout(resolve, 500); + }); +} + +/** + * Unloads only the first instance, used to verify that unloading one instance does not disturb the + * other. + */ +export function unloadFirst() { + var first = _instances[INSTANCES[0].id]; + if (first) { + first.appInsights.unload(false); + } + + return getDiagnostics(); +} + +/** + * Collects the information used to verify that the two instances are genuinely independent. + */ +export function getDiagnostics() { + var diagnostics = { + errors: _errors.slice(), + instances: [], + globals: {} + }; + + for (var lp = 0; lp < INSTANCES.length; lp++) { + var def = INSTANCES[lp]; + var inst = _instances[def.id]; + if (!inst) { + continue; + } + + var core = inst.appInsights.core; + var channels = []; + try { + var coreChannels = core.getChannels() || []; + for (var c = 0; c < coreChannels.length; c++) { + channels.push({ + identifier: coreChannels[c].identifier, + priority: coreChannels[c].priority + }); + } + } catch (e) { + _recordError("getChannels(" + def.id + ")", e); + } + + diagnostics.instances.push({ + id: def.id, + globalName: def.globalName, + serviceName: def.serviceName, + marker: def.marker, + iKey: core && core.config ? core.config.instrumentationKey : null, + isInitialized: !!(core && core.isInitialized && core.isInitialized()), + channelIdentifier: inst.channel.identifier, + channelPriority: inst.channel.priority, + channels: channels, + // The identity of the objects matters: two instances must never share a core, a channel + // or a configuration object. + coreId: _objectId(core), + channelId: _objectId(inst.channel), + configId: _objectId(core ? core.config : null), + resourceServiceName: _readResourceServiceName(core, inst.channel) + }); + } + + // Record the Application Insights related globals so that unexpected collisions are visible + var globalNames = []; + for (var name in window) { + if (name.indexOf("appInsights") === 0 || name.indexOf("Microsoft") === 0 || name.indexOf("ai") === 0) { + globalNames.push(name); + } + } + diagnostics.globals.names = globalNames.sort(); + + return diagnostics; +} + +function _readResourceServiceName(core, channel) { + try { + var extCfg = core && core.config && core.config.extensionConfig; + var cfg = extCfg ? extCfg[channel.identifier] : null; + return cfg && cfg.resourceAttributes ? cfg.resourceAttributes["service.name"] : null; + } catch (e) { + return null; + } +} + +var _objectIds = []; + +/** + * Returns a stable identity for an object so that two instances sharing an object can be detected. + */ +function _objectId(obj) { + if (!obj) { + return null; + } + + for (var lp = 0; lp < _objectIds.length; lp++) { + if (_objectIds[lp] === obj) { + return lp; + } + } + + _objectIds.push(obj); + + return _objectIds.length - 1; +} + +/** + * Runs the whole sequence for a page: generate every kind of telemetry, then flush. + * @param pageName - The logical name of the page. + */ +export function runPage(pageName) { + var summary = { page: pageName, generated: 0, spans: 0 }; + + summary.generated = generateAll(pageName); + summary.spans = generateSpans(pageName); + + return generateDependencies().then(function () { + // Give the dependency plugin a moment to record the completed requests + return new Promise(function (resolve) { + setTimeout(resolve, 300); + }); + }).then(function () { + return flushAll(); + }).then(function () { + summary.diagnostics = getDiagnostics(); + window.__otlpPageComplete = summary; + return summary; + }); +} + +export { initSku, getSkuDiagnostics, generateSku, flushSku, unloadSku } from "./custom-sku"; diff --git a/examples/otlp/tools/automated-test.js b/examples/otlp/tools/automated-test.js new file mode 100644 index 000000000..b22fce83a --- /dev/null +++ b/examples/otlp/tools/automated-test.js @@ -0,0 +1,303 @@ +/* + * Automated end to end test for the Application Insights OTLP channel. + * + * Starts the mock collector, drives the example site through a real browser using Puppeteer, and + * then validates every OTLP payload the collector received along with the instance isolation + * diagnostics reported by each page. + * + * Usage: node tools/automated-test.js [--headful] + */ +const path = require("path"); +const { spawn } = require("child_process"); +const http = require("http"); +const { validate } = require("./validate"); + +const PORT = Number(process.env.OTLP_EXAMPLE_PORT || 8099); +const BASE = "http://localhost:" + PORT; +const PAGES = ["index.html", "products.html", "checkout.html"]; + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + try { + resolve({ status: res.statusCode, body: JSON.parse(body) }); + } catch (e) { + resolve({ status: res.statusCode, body }); + } + }); + }).on("error", reject); + }); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForServer(timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await httpGet(BASE + "/__collected"); + return true; + } catch (e) { + await delay(200); + } + } + + return false; +} + +function startServer() { + const serverPath = path.join(__dirname, "server.js"); + const child = spawn(process.execPath, [serverPath], { + stdio: ["ignore", "pipe", "pipe"], + env: Object.assign({}, process.env, { OTLP_EXAMPLE_PORT: String(PORT) }) + }); + + child.stdout.on("data", () => { /* keep the server quiet during the test */ }); + child.stderr.on("data", (data) => console.error("[server] " + data.toString().trim())); + + return child; +} + +/** + * Detects a server that is already listening on the port, so that the test reuses it rather than + * silently failing to bind a second one (which would leave the results depending on whichever server + * happened to win the port). + */ +async function isServerAlreadyRunning() { + try { + await httpGet(BASE + "/__collected"); + return true; + } catch (e) { + return false; + } +} + +/** + * Checks the per page diagnostics that describe whether the two instances stayed independent. + */ +function checkIsolation(pageName, diagnostics, failures) { + function check(condition, description) { + if (!condition) { + failures.push("[" + pageName + "] " + description); + } + } + + check(diagnostics, "diagnostics were reported"); + if (!diagnostics) { + return; + } + + check((diagnostics.errors || []).length === 0, + "no initialization errors (" + (diagnostics.errors || []).join(" | ") + ")"); + check(diagnostics.instances && diagnostics.instances.length === 2, "both instances were created"); + + if (!diagnostics.instances || diagnostics.instances.length !== 2) { + return; + } + + const [a, b] = diagnostics.instances; + + check(a.coreId !== b.coreId, "each instance has its own core"); + check(a.channelId !== b.channelId, "each instance has its own OTLP channel"); + check(a.configId !== b.configId, "each instance has its own configuration object"); + check(!!a.iKey && !!b.iKey && a.iKey !== b.iKey, "each instance kept its own instrumentation key"); + check(a.resourceServiceName !== b.resourceServiceName, + "each instance kept its own service.name"); + check(a.isInitialized && b.isInitialized, "both instances report as initialized"); + check(a.channelPriority === b.channelPriority && a.channelPriority >= 500, + "the channel priority is in the channel range"); + + // Each instance's core must actually contain its own OTLP channel + [a, b].forEach((inst) => { + const hasOtlp = (inst.channels || []).some((ch) => ch.identifier === "OtlpChannel"); + check(hasOtlp, "instance '" + inst.id + "' has the OTLP channel registered as a channel"); + }); +} + +function postReset() { + return new Promise((resolve, reject) => { + const req = http.request(BASE + "/__reset", { method: "POST" }, resolve); + req.on("error", reject); + req.end(); + }); +} + +/** + * Requests that are expected to fail, because the example deliberately issues them in order to + * produce an unsuccessful dependency span. + */ +const EXPECTED_FAILURES = ["/api/missing"]; + +async function run() { + let puppeteer; + try { + puppeteer = require("puppeteer"); + } catch (e) { + console.error("Puppeteer is not available. Install the example dependencies first."); + process.exit(2); + } + + const failures = []; + const reusedServer = await isServerAlreadyRunning(); + if (reusedServer) { + console.log("Note: a server is already listening on " + BASE + ", reusing it."); + console.log(" Stop it first if you want a completely isolated run."); + console.log(""); + } + + const server = reusedServer ? null : startServer(); + + let browser = null; + try { + if (!(await waitForServer(15000))) { + throw new Error("The mock collector did not start on " + BASE); + } + + await postReset(); + + browser = await puppeteer.launch({ + headless: process.argv.indexOf("--headful") === -1 ? "new" : false, + args: ["--no-sandbox", "--disable-dev-shm-usage"] + }); + + for (const pageName of PAGES) { + const page = await browser.newPage(); + const pageErrors = []; + const badResponses = []; + + page.on("pageerror", (err) => pageErrors.push(String(err))); + page.on("console", (msg) => { + // A failed resource load is reported here without a url, so those are asserted on + // through the response handler below instead of by matching console text. + if (msg.type() === "error" && msg.text().indexOf("Failed to load resource") === -1) { + pageErrors.push(msg.text()); + } + }); + page.on("response", (response) => { + const status = response.status(); + const url = response.url(); + const expected = EXPECTED_FAILURES.some((suffix) => url.indexOf(suffix) !== -1); + if (status >= 400 && !expected) { + badResponses.push(status + " " + url); + } + }); + + await page.goto(BASE + "/" + pageName + "?autorun", { waitUntil: "load" }); + + await page.waitForFunction("window.__otlpAutoRunComplete === true", { timeout: 30000 }); + + // Allow the final batch interval to elapse so that everything has been exported + await delay(2500); + + const summary = await page.evaluate("window.__otlpPageComplete"); + checkIsolation(pageName, summary && summary.diagnostics, failures); + + if (pageErrors.length) { + failures.push("[" + pageName + "] the page reported errors: " + pageErrors.join(" | ")); + } + + if (badResponses.length) { + failures.push("[" + pageName + "] unexpected failed request(s): " + badResponses.join(", ")); + } + + console.log(" visited " + pageName + " - generated " + (summary ? summary.generated : 0) + + " items and " + (summary ? summary.spans : 0) + " span(s)"); + + await page.close(); + } + + // Give any trailing batch a chance to arrive + await delay(1500); + + const collected = await httpGet(BASE + "/__collected"); + const report = validate(collected.body.requests); + + console.log(""); + console.log("OTLP requests received : " + collected.body.otlpRequests); + console.log("Spans exported : " + report.summary.spans); + console.log("Log records exported : " + report.summary.logs); + console.log("Services seen : " + JSON.stringify(report.summary.services)); + console.log("Span kinds : " + JSON.stringify(report.summary.spanKinds)); + console.log("Telemetry types : " + JSON.stringify(report.summary.telemetryTypes)); + console.log("Payload assertions : " + report.passedCount + " passed, " + + report.failedCount + " failed"); + + report.failures.forEach((failure) => { + failures.push("[payload] " + failure.description + + (failure.detail === null ? "" : " -- " + JSON.stringify(failure.detail))); + }); + + // Every page must have contributed telemetry for both services + ["home", "products", "checkout"].forEach((pageName) => { + const seen = collected.body.requests.some((request) => + JSON.stringify(request.body).indexOf("\"" + pageName + "\"") !== -1); + if (!seen) { + failures.push("[coverage] no telemetry was received for the '" + pageName + "' page"); + } + }); + + // ------------------------------------------------------------------------------------ + // Unloading one instance must not disturb the other. The collector is reset, the first + // instance is unloaded, and then only the second instance may still export telemetry. + // ------------------------------------------------------------------------------------ + await postReset(); + + const unloadPage = await browser.newPage(); + await unloadPage.goto(BASE + "/checkout.html", { waitUntil: "load" }); + await unloadPage.evaluate("otlpExample.unloadFirst()"); + await unloadPage.evaluate("otlpExample.runPage('checkout-after-unload')"); + await delay(4000); + await unloadPage.close(); + await delay(1000); + + const afterUnload = await httpGet(BASE + "/__collected"); + const afterReport = validate(afterUnload.body.requests, { + expectedServices: ["checkout-widget"], + expectedTelemetryTypes: ["MessageData", "ExceptionData", "EventData"] + }); + + const servicesAfterUnload = Object.keys(afterReport.summary.services); + console.log(""); + console.log("After unloading the first instance, services still exporting: " + + JSON.stringify(servicesAfterUnload)); + + if (servicesAfterUnload.indexOf("storefront-web") !== -1) { + failures.push("[unload] the unloaded instance kept exporting telemetry"); + } + + if (servicesAfterUnload.indexOf("checkout-widget") === -1) { + failures.push("[unload] the surviving instance stopped exporting telemetry"); + } + + afterReport.failures.forEach((failure) => { + failures.push("[unload payload] " + failure.description); + }); + } catch (e) { + failures.push("[harness] " + e.message); + } finally { + if (browser) { + await browser.close(); + } + if (server) { + server.kill(); + } + } + + console.log(""); + if (failures.length) { + console.error("FAILED (" + failures.length + " issue(s)):"); + failures.forEach((f) => console.error(" - " + f)); + process.exit(1); + } + + console.log("PASSED - all OTLP payloads are valid and the instances stayed isolated."); + process.exit(0); +} + +run(); diff --git a/examples/otlp/tools/check-custom-sku.js b/examples/otlp/tools/check-custom-sku.js new file mode 100644 index 000000000..9f592fc19 --- /dev/null +++ b/examples/otlp/tools/check-custom-sku.js @@ -0,0 +1,111 @@ +/* + * Verifies that a custom SKU which places the real OfflineChannel in front of the OTLP channel still + * delivers telemetry to the OTLP endpoint. + */ +const http = require("http"); +const puppeteer = require("puppeteer"); + +const BASE = "http://localhost:8099"; +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const c = []; + res.on("data", (d) => c.push(d)); + res.on("end", () => resolve(JSON.parse(Buffer.concat(c).toString("utf8")))); + }).on("error", reject); + }); +} + +function postReset() { + return new Promise((resolve, reject) => { + const req = http.request(BASE + "/__reset", { method: "POST" }, resolve); + req.on("error", reject); + req.end(); + }); +} + +(async () => { + const failures = []; + await postReset(); + + const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] }); + const page = await browser.newPage(); + + const pageErrors = []; + page.on("pageerror", (e) => pageErrors.push(String(e))); + + await page.goto(BASE + "/custom-sku.html", { waitUntil: "load" }); + await page.waitForFunction("window.__customSkuReady === true", { timeout: 20000 }); + await delay(500); + + const diag = await page.evaluate(() => otlpExample.getSkuDiagnostics()); + console.log("Resolved chain:"); + diag.channels.forEach((c) => console.log(" " + c.identifier + " priority " + c.priority)); + console.log("otlpIsLast : " + diag.otlpIsLast); + console.log("otlpResolvableByIdentifier : " + diag.otlpResolvableByIdentifier); + console.log("offlineSupport.url : " + (diag.offlineSupport && diag.offlineSupport.url)); + console.log("offlineSupport.canSerialize: " + (diag.offlineSupport && diag.offlineSupport.canSerialize)); + + if (diag.channels.length !== 2) { failures.push("expected two channels in the queue"); } + if (!diag.otlpIsLast) { failures.push("the OTLP channel did not sort last"); } + if (!diag.otlpResolvableByIdentifier) { failures.push("the OTLP channel was not resolvable by identifier"); } + if (!(diag.offlineSupport && diag.offlineSupport.canSerialize)) { + failures.push("the OTLP channel did not provide usable offline support"); + } + + // Generate telemetry through the chain. Driven directly rather than through the button so the + // assertion cannot race the click handler. + const tracked = await page.evaluate(() => otlpExample.generateSku()); + await page.evaluate(() => otlpExample.flushSku()); + await delay(5000); + + console.log(""); + console.log("items tracked through the SKU : " + tracked); + + if (!tracked) { + failures.push("the SKU tracked nothing, it was probably not initialized"); + } + + const collected = await httpGet(BASE + "/__collected"); + let records = 0; + const services = {}; + collected.requests.forEach((r) => { + if (!r.body) { return; } + (r.body.resourceSpans || r.body.resourceLogs || []).forEach((res) => { + (res.resource.attributes || []).forEach((a) => { + if (a.key === "service.name") { services[a.value.stringValue] = true; } + }); + (res.scopeSpans || res.scopeLogs || []).forEach((s) => { + records += (s.spans || s.logRecords || []).length; + }); + }); + }); + + console.log(""); + console.log("records exported through OfflineChannel -> OtlpChannel : " + records); + console.log("services seen : " + Object.keys(services).join(", ")); + + if (records < 3) { + failures.push("expected at least 3 records to reach the OTLP endpoint, got " + records); + } + if (!services["custom-sku"]) { + failures.push("no telemetry arrived under the custom-sku service"); + } + if (pageErrors.length) { + failures.push("page errors: " + pageErrors.join(" | ")); + } + + await browser.close(); + + console.log(""); + if (failures.length) { + console.error("FAILED:"); + failures.forEach((f) => console.error(" - " + f)); + process.exit(1); + } + + console.log("PASSED - the OTLP channel works when chained behind the real OfflineChannel."); + process.exit(0); +})(); diff --git a/examples/otlp/tools/check-navigation.js b/examples/otlp/tools/check-navigation.js new file mode 100644 index 000000000..fab22b0fd --- /dev/null +++ b/examples/otlp/tools/check-navigation.js @@ -0,0 +1,46 @@ +/* Verifies that the collector choice survives navigating between pages via the nav links. */ +const puppeteer = require("puppeteer"); + +const BASE = "http://localhost:8099"; +const TAP = BASE + "/tap"; +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] }); + const page = await browser.newPage(); + + // Land on the tapped url, exactly as the Edge tab did + await page.goto(BASE + "/index.html?collector=" + encodeURIComponent(TAP), { waitUntil: "load" }); + await delay(800); + + const homeBanner = await page.evaluate(() => document.getElementById("endpoint").textContent.trim()); + console.log("home banner : " + homeBanner.substring(0, 100)); + console.log("home usingTap : " + await page.evaluate(() => otlpExample.isUsingTap())); + + // Click the Checkout nav link -- the exact action that previously lost the setting + await Promise.all([ + page.waitForNavigation({ waitUntil: "load" }), + page.click('nav a[href*="checkout"]') + ]); + await delay(800); + + console.log(""); + console.log("checkout url : " + page.url()); + console.log("checkout endpoint: " + await page.evaluate(() => otlpExample.getEndpoint())); + console.log("checkout usingTap: " + await page.evaluate(() => otlpExample.isUsingTap())); + + // Now generate telemetry from the navigated-to page + await page.click("#generate"); + await delay(6000); + + // And prove a page opened with NO query string at all still uses the stored choice + const fresh = await browser.newPage(); + await fresh.goto(BASE + "/products.html", { waitUntil: "load" }); + await delay(800); + console.log(""); + console.log("fresh tab, no query string:"); + console.log(" endpoint : " + await fresh.evaluate(() => otlpExample.getEndpoint())); + console.log(" usingTap : " + await fresh.evaluate(() => otlpExample.isUsingTap())); + + await browser.close(); +})(); diff --git a/examples/otlp/tools/diagnose.js b/examples/otlp/tools/diagnose.js new file mode 100644 index 000000000..4becf58ef --- /dev/null +++ b/examples/otlp/tools/diagnose.js @@ -0,0 +1,79 @@ +/* Diagnostic: dump the OTLP spans produced from a real startSpan() call. */ +const path = require("path"); +const { spawn } = require("child_process"); +const http = require("http"); + +const PORT = 8098; +const BASE = "http://localhost:" + PORT; + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")))); + }).on("error", reject); + }); +} + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const server = spawn(process.execPath, [path.join(__dirname, "server.js")], { + stdio: "ignore", + env: Object.assign({}, process.env, { OTLP_EXAMPLE_PORT: String(PORT) }) + }); + + await delay(1500); + + const puppeteer = require("puppeteer"); + const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] }); + const page = await browser.newPage(); + + await page.goto(BASE + "/index.html?autorun", { waitUntil: "load" }); + await page.waitForFunction("window.__otlpAutoRunComplete === true", { timeout: 30000 }); + await delay(2500); + + const collected = await httpGet(BASE + "/__collected"); + + const spans = []; + collected.requests.forEach((r) => { + if (r.signal !== "traces" || !r.body) { return; } + (r.body.resourceSpans || []).forEach((rs) => { + (rs.scopeSpans || []).forEach((ss) => { + (ss.spans || []).forEach((s) => spans.push(s)); + }); + }); + }); + + console.log("total spans: " + spans.length); + console.log("span names: " + JSON.stringify(spans.map((s) => s.name))); + + const suspect = spans.filter((s) => !s.spanId || !/^[0-9a-f]{16}$/.test(s.spanId || "")); + console.log("\n=== spans with a bad/missing spanId: " + suspect.length + " ==="); + if (suspect.length) { + console.log(JSON.stringify(suspect[0], null, 2)); + } + + const dupes = spans.filter((s) => { + const seen = {}; + return (s.attributes || []).some((a) => { + if (seen[a.key]) { return true; } + seen[a.key] = true; + return false; + }); + }); + console.log("\n=== spans with duplicate attribute keys: " + dupes.length + " ==="); + if (dupes.length) { + const s = dupes[0]; + console.log("name: " + s.name + " kind: " + s.kind); + const counts = {}; + (s.attributes || []).forEach((a) => { counts[a.key] = (counts[a.key] || 0) + 1; }); + Object.keys(counts).forEach((k) => { if (counts[k] > 1) { console.log(" duplicated key: " + k + " x" + counts[k]); } }); + console.log(JSON.stringify(s, null, 2)); + } + + await browser.close(); + server.kill(); + process.exit(0); +})(); diff --git a/examples/otlp/tools/dump-samples.js b/examples/otlp/tools/dump-samples.js new file mode 100644 index 000000000..da6d699f2 --- /dev/null +++ b/examples/otlp/tools/dump-samples.js @@ -0,0 +1,82 @@ +/* Dump one full example of each span shape the channel produces. */ +const path = require("path"); +const { spawn } = require("child_process"); +const http = require("http"); + +const PORT = 8096; +const BASE = "http://localhost:" + PORT; +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const c = []; + res.on("data", (d) => c.push(d)); + res.on("end", () => resolve(JSON.parse(Buffer.concat(c).toString("utf8")))); + }).on("error", reject); + }); +} + +(async () => { + const server = spawn(process.execPath, [path.join(__dirname, "server.js")], { + stdio: "ignore", + env: Object.assign({}, process.env, { OTLP_EXAMPLE_PORT: String(PORT) }) + }); + await delay(1500); + + const puppeteer = require("puppeteer"); + const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] }); + const page = await browser.newPage(); + await page.goto(BASE + "/index.html?autorun", { waitUntil: "load" }); + await page.waitForFunction("window.__otlpAutoRunComplete === true", { timeout: 30000 }); + await delay(2500); + + const collected = await httpGet(BASE + "/__collected"); + + const traceReq = collected.requests.filter((r) => r.signal === "traces")[0]; + console.log("=========== RAW REQUEST (exactly what was POSTed to /v1/traces) ==========="); + console.log("url : " + traceReq.url); + console.log("content-type: " + traceReq.contentType); + console.log("body bytes : " + traceReq.rawLength); + console.log(""); + console.log("--- first 700 chars of the raw body ---"); + console.log(traceReq.raw.substring(0, 700) + " ..."); + + const spans = []; + collected.requests.forEach((r) => { + if (r.signal !== "traces" || !r.body) { return; } + (r.body.resourceSpans || []).forEach((rs) => { + (rs.scopeSpans || []).forEach((ss) => (ss.spans || []).forEach((s) => spans.push(s))); + }); + }); + + const pick = (predicate) => spans.filter(predicate)[0]; + + console.log("\n\n=========== A: span from a real startSpan() OTel span ==========="); + console.log(JSON.stringify(pick((s) => s.name.indexOf("example-span") === 0), null, 2)); + + console.log("\n\n=========== B: auto-collected fetch dependency (CLIENT) ==========="); + console.log(JSON.stringify(pick((s) => s.name.indexOf("/api/products") !== -1), null, 2)); + + console.log("\n\n=========== C: the deliberately failing request (status ERROR) ==========="); + console.log(JSON.stringify(pick((s) => s.name.indexOf("/api/missing") !== -1), null, 2)); + + console.log("\n\n=========== D: page view span (generated span id) ==========="); + console.log(JSON.stringify(pick((s) => s.name === "home"), null, 2)); + + const logReq = collected.requests.filter((r) => r.signal === "logs")[0]; + const logRecords = []; + (logReq.body.resourceLogs || []).forEach((rl) => { + (rl.scopeLogs || []).forEach((sl) => (sl.logRecords || []).forEach((l) => logRecords.push(l))); + }); + console.log("\n\n=========== E: exception log record ==========="); + console.log(JSON.stringify(logRecords.filter((l) => + (l.attributes || []).some((a) => a.key === "exception.type"))[0], null, 2)); + + console.log("\n\n=========== F: the resource both share ==========="); + console.log(JSON.stringify(traceReq.body.resourceSpans[0].resource, null, 2)); + + await browser.close(); + server.kill(); + process.exit(0); +})(); diff --git a/examples/otlp/tools/manual-check.js b/examples/otlp/tools/manual-check.js new file mode 100644 index 000000000..456f1bc58 --- /dev/null +++ b/examples/otlp/tools/manual-check.js @@ -0,0 +1,69 @@ +/* One off verification that the manual (button driven) flow works end to end. */ +const path = require("path"); +const { spawn } = require("child_process"); + +const PORT = 8097; +const BASE = "http://localhost:" + PORT; +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + const server = spawn(process.execPath, [path.join(__dirname, "server.js")], { + stdio: "ignore", + env: Object.assign({}, process.env, { OTLP_EXAMPLE_PORT: String(PORT) }) + }); + await delay(1500); + + const puppeteer = require("puppeteer"); + const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] }); + const page = await browser.newPage(); + + await page.goto(BASE + "/index.html", { waitUntil: "load" }); + await delay(600); + + // The isolation checklist must render as soon as the page initializes + const initialIsolation = await page.evaluate( + "Array.prototype.map.call(document.querySelectorAll('#isolation li'), function (li) { return li.textContent; })"); + console.log("Isolation checklist on load:"); + initialIsolation.forEach((line) => console.log(" " + line)); + + await page.click("#generate"); + await delay(4000); + + await page.click("#validate"); + await delay(1500); + + const validationText = await page.evaluate("document.getElementById('validation').textContent"); + const report = JSON.parse(validationText); + console.log(""); + console.log("Manual validation button -> ok=" + report.ok + " passed=" + report.passedCount + + " failed=" + report.failedCount); + + const logText = await page.evaluate("document.getElementById('log').textContent"); + console.log(""); + console.log("Activity log:"); + logText.split("\n").forEach((l) => { if (l.trim()) { console.log(" " + l); } }); + + // Now exercise the unload-one-instance flow on the checkout page + const checkout = await browser.newPage(); + await checkout.goto(BASE + "/checkout.html", { waitUntil: "load" }); + await delay(600); + await checkout.click("#unloadFirst"); + await delay(500); + await checkout.click("#generate"); + await delay(4000); + + const afterUnload = await checkout.evaluate("window.__otlpPageComplete ? window.__otlpPageComplete.generated : -1"); + const checkoutLog = await checkout.evaluate("document.getElementById('log').textContent"); + console.log(""); + console.log("After unloading only the first instance, telemetry generated: " + afterUnload); + console.log("Checkout log:"); + checkoutLog.split("\n").forEach((l) => { if (l.trim()) { console.log(" " + l); } }); + + const allPass = initialIsolation.every((l) => l.indexOf("PASS") === 0); + console.log(""); + console.log(allPass && report.ok ? "MANUAL FLOW OK" : "MANUAL FLOW PROBLEM"); + + await browser.close(); + server.kill(); + process.exit(allPass && report.ok ? 0 : 1); +})(); diff --git a/examples/otlp/tools/probe-sku.js b/examples/otlp/tools/probe-sku.js new file mode 100644 index 000000000..a0efd41ed --- /dev/null +++ b/examples/otlp/tools/probe-sku.js @@ -0,0 +1,77 @@ +/* Compares the two offline-channel configurations to see which one starves the OTLP channel. */ +const http = require("http"); +const puppeteer = require("puppeteer"); + +const BASE = "http://localhost:8099"; +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const c = []; + res.on("data", (d) => c.push(d)); + res.on("end", () => resolve(JSON.parse(Buffer.concat(c).toString("utf8")))); + }).on("error", reject); + }); +} + +function postReset() { + return new Promise((resolve, reject) => { + const req = http.request(BASE + "/__reset", { method: "POST" }, resolve); + req.on("error", reject); + req.end(); + }); +} + +async function countRecords() { + const collected = await httpGet(BASE + "/__collected"); + let records = 0; + collected.requests.forEach((r) => { + if (!r.body) { return; } + (r.body.resourceSpans || r.body.resourceLogs || []).forEach((res) => { + (res.scopeSpans || res.scopeLogs || []).forEach((s) => { + records += (s.spans || s.logRecords || []).length; + }); + }); + }); + return records; +} + +async function runMode(page, named) { + await postReset(); + await page.evaluate((n) => { + otlpExample.unloadSku(); + otlpExample.initSku(n); + }, named); + await delay(500); + + const online = await page.evaluate(() => navigator.onLine); + await page.evaluate(() => otlpExample.generateSku()); + await page.evaluate(() => otlpExample.flushSku()); + await delay(4000); + + const records = await countRecords(); + console.log("primaryOnlineChannelId=" + (named ? "[OtlpChannel]" : "default") + + " navigator.onLine=" + online + " -> records exported: " + records); + + return records; +} + +(async () => { + const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"] }); + const page = await browser.newPage(); + page.on("pageerror", (e) => console.error("PAGE ERROR: " + e)); + + await page.goto(BASE + "/custom-sku.html", { waitUntil: "load" }); + await page.waitForFunction("window.__customSkuReady === true", { timeout: 20000 }); + + const withNamed = await runMode(page, true); + const withDefault = await runMode(page, false); + + console.log(""); + console.log("named: " + withNamed + " records"); + console.log("default: " + withDefault + " records"); + + await browser.close(); + process.exit(0); +})(); diff --git a/examples/otlp/tools/server.js b/examples/otlp/tools/server.js new file mode 100644 index 000000000..a497b1f67 --- /dev/null +++ b/examples/otlp/tools/server.js @@ -0,0 +1,361 @@ +/* + * A dependency free static file server and mock OTLP collector for the Application Insights OTLP + * channel example. + * + * Endpoints: + * POST /v1/traces - accepts an OTLP trace export request + * POST /v1/logs - accepts an OTLP log export request + * POST /breeze/v2/track - accepts (and discards) Application Insights ingestion, so that the + * built in sender never reaches the real endpoint during a test + * GET /api/* - simple endpoints used to generate dependency telemetry + * GET /__collected - everything the collector has received + * GET /__validate - runs the validation rules over everything received so far + * POST /__reset - clears the collected data + */ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const { validate } = require("./validate"); + +const PORT = Number(process.env.OTLP_EXAMPLE_PORT || 8099); +const COLLECTOR_URL = process.env.OTLP_COLLECTOR_URL || "http://localhost:4318"; +const PUBLIC_DIR = path.resolve(__dirname, "../public"); + +/** Every OTLP request the collector has received. */ +const collected = []; +/** Every Application Insights ingestion request, kept only so that a manual run can see it. */ +const breeze = []; +/** + * Every OTLP request that passed through the tap on its way to the real OpenTelemetry Collector, + * together with the response the collector gave. This is what the SDK actually sent, captured before + * the collector saw it. + */ +const tapped = []; + +const MIME_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".ico": "image/x-icon" +}; + +function sendJson(res, status, body) { + const payload = JSON.stringify(body, null, 2); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(payload), + "Access-Control-Allow-Origin": "*" + }); + res.end(payload); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function serveStatic(req, res, pathname) { + let filePath = path.join(PUBLIC_DIR, pathname === "/" ? "/index.html" : pathname); + + // Prevent escaping the public directory + if (!filePath.startsWith(PUBLIC_DIR)) { + sendJson(res, 403, { error: "forbidden" }); + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found: " + pathname); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { + "Content-Type": MIME_TYPES[ext] || "application/octet-stream", + "Cache-Control": "no-store" + }); + res.end(data); + }); +} + +async function handleOtlp(req, res, signal, url) { + const raw = await readBody(req); + let body = null; + let parseError = null; + + try { + body = JSON.parse(raw); + } catch (e) { + parseError = e.message; + } + + collected.push({ + signal, + url, + contentType: req.headers["content-type"] || "", + headers: req.headers, + raw, + rawLength: raw.length, + body, + parseError, + receivedAt: new Date().toISOString() + }); + + // An empty JSON object is the standard success response for OTLP/HTTP + sendJson(res, 200, {}); +} + +/** + * Forwards a request to the real OpenTelemetry Collector and resolves with its response. + */ +function forwardToCollector(signalPath, raw, contentType) { + return new Promise((resolve) => { + let target; + try { + target = new URL(COLLECTOR_URL + signalPath); + } catch (e) { + return resolve({ status: 0, body: "", error: "Invalid collector url: " + COLLECTOR_URL }); + } + + const started = Date.now(); + const request = http.request({ + hostname: target.hostname, + port: target.port || 80, + path: target.pathname, + method: "POST", + headers: { + "Content-Type": contentType || "application/json", + "Content-Length": Buffer.byteLength(raw) + } + }, (response) => { + const chunks = []; + response.on("data", (c) => chunks.push(c)); + response.on("end", () => resolve({ + status: response.statusCode, + body: Buffer.concat(chunks).toString("utf8"), + durationMs: Date.now() - started + })); + }); + + request.on("error", (e) => resolve({ + status: 0, + body: "", + error: e.message, + durationMs: Date.now() - started + })); + + request.end(raw); + }); +} + +/** + * The tap sits between the SDK and the real collector. It records exactly what the SDK sent, forwards + * it untouched, and relays the collector's real response back -- so a rejection by the collector is + * seen by the SDK exactly as it would be without the tap. + */ +async function handleTap(req, res, signalPath) { + const raw = await readBody(req); + const contentType = req.headers["content-type"] || ""; + + let body = null; + let parseError = null; + try { + body = JSON.parse(raw); + } catch (e) { + parseError = e.message; + } + + const entry = { + index: tapped.length, + signal: signalPath.indexOf("traces") !== -1 ? "traces" : "logs", + signalPath, + contentType, + raw, + rawLength: raw.length, + body, + parseError, + recordCount: _countRecords(body), + sentAt: new Date().toISOString(), + collector: null + }; + tapped.push(entry); + + const result = await forwardToCollector(signalPath, raw, contentType); + entry.collector = { + url: COLLECTOR_URL + signalPath, + status: result.status, + body: result.body, + error: result.error || null, + durationMs: result.durationMs, + accepted: result.status >= 200 && result.status < 300 + }; + + if (result.status === 0) { + // The collector is unreachable; report it clearly rather than pretending the export worked + return sendJson(res, 502, { + error: "The OTLP collector at " + COLLECTOR_URL + " could not be reached", + detail: result.error + }); + } + + res.writeHead(result.status, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*" + }); + res.end(result.body || "{}"); +} + +function _countRecords(body) { + if (!body) { + return 0; + } + + let count = 0; + const resources = body.resourceSpans || body.resourceLogs || []; + resources.forEach((resource) => { + const scopes = resource.scopeSpans || resource.scopeLogs || []; + scopes.forEach((scope) => { + count += (scope.spans || scope.logRecords || []).length; + }); + }); + + return count; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://" + (req.headers.host || "localhost")); + const pathname = url.pathname; + + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Headers": "*" + }); + res.end(); + return; + } + + try { + if (req.method === "POST" && pathname === "/v1/traces") { + return await handleOtlp(req, res, "traces", pathname); + } + + if (req.method === "POST" && pathname === "/v1/logs") { + return await handleOtlp(req, res, "logs", pathname); + } + + if (req.method === "POST" && pathname.indexOf("/tap/") === 0) { + // /tap/v1/traces -> forwarded to /v1/traces + return await handleTap(req, res, pathname.substring("/tap".length)); + } + + if (req.method === "POST" && pathname === "/breeze/v2/track") { + const raw = await readBody(req); + breeze.push({ length: raw.length, receivedAt: new Date().toISOString() }); + return sendJson(res, 200, { itemsReceived: 1, itemsAccepted: 1, errors: [] }); + } + + if (pathname === "/__tapped") { + // What the SDK actually sent to the collector, and how the collector answered + const rejected = tapped.filter((t) => t.collector && !t.collector.accepted); + return sendJson(res, 200, { + collectorUrl: COLLECTOR_URL, + requests: tapped.length, + records: tapped.reduce((sum, t) => sum + t.recordCount, 0), + acceptedByCollector: tapped.length - rejected.length, + rejectedByCollector: rejected.length, + entries: tapped + }); + } + + if (pathname === "/__tap-validate") { + // Validate exactly what was sent to the collector, before the collector touched it + const asRequests = tapped.map((t) => ({ + signal: t.signal, + url: t.signalPath, + contentType: t.contentType, + body: t.body + })); + + const report = tapped.length ? validate(asRequests) : { + ok: false, passedCount: 0, failedCount: 1, + failures: [{ description: "Nothing has been sent to the collector yet", detail: null }], + summary: {} + }; + + report.collectorUrl = COLLECTOR_URL; + report.rejectedByCollector = tapped.filter((t) => t.collector && !t.collector.accepted) + .map((t) => ({ index: t.index, status: t.collector.status, body: t.collector.body })); + + return sendJson(res, report.ok && !report.rejectedByCollector.length ? 200 : 500, report); + } + + if (pathname === "/__collected") { + return sendJson(res, 200, { + otlpRequests: collected.length, + breezeRequests: breeze.length, + requests: collected + }); + } + + if (pathname === "/__validate") { + const report = validate(collected); + report.breezeRequests = breeze.length; + return sendJson(res, report.ok ? 200 : 500, report); + } + + if (req.method === "POST" && pathname === "/__reset") { + collected.length = 0; + breeze.length = 0; + tapped.length = 0; + return sendJson(res, 200, { ok: true }); + } + + if (pathname === "/favicon.ico") { + res.writeHead(204, { "Cache-Control": "no-store" }); + res.end(); + return; + } + + if (pathname.startsWith("/api/")) { + if (pathname === "/api/missing") { + // Intentionally missing, used by the example to produce a failed dependency + return sendJson(res, 404, { error: "not found" }); + } + + return sendJson(res, 200, { + endpoint: pathname, + items: [{ id: 1, name: "Widget" }, { id: 2, name: "Gadget" }] + }); + } + + return serveStatic(req, res, pathname); + } catch (e) { + sendJson(res, 500, { error: e.message, stack: e.stack }); + } +}); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log("OTLP example running at http://localhost:" + PORT + "/"); + // eslint-disable-next-line no-console + console.log(" mock collector : POST /v1/traces POST /v1/logs"); + // eslint-disable-next-line no-console + console.log(" tap -> real collector: POST /tap/v1/traces (forwards to " + COLLECTOR_URL + ")"); + // eslint-disable-next-line no-console + console.log(" inspector : http://localhost:" + PORT + "/inspect.html"); + // eslint-disable-next-line no-console + console.log(" sent to collector : GET /__tapped GET /__tap-validate"); + // eslint-disable-next-line no-console + console.log(" received by mock : GET /__collected GET /__validate"); +}); + +module.exports = { server, collected, tapped }; diff --git a/examples/otlp/tools/validate.js b/examples/otlp/tools/validate.js new file mode 100644 index 000000000..f71393766 --- /dev/null +++ b/examples/otlp/tools/validate.js @@ -0,0 +1,484 @@ +/* + * Validation rules for the OTLP payloads produced by the Application Insights OTLP channel. + * + * This module is deliberately dependency free and is used both by the mock collector (so a manual + * run can see the results in the browser) and by the automated Puppeteer driven test. + */ + +const HEX_32 = /^[0-9a-f]{32}$/; +const HEX_16 = /^[0-9a-f]{16}$/; +const DIGITS = /^[0-9]+$/; + +const ANY_VALUE_MEMBERS = [ + "stringValue", "boolValue", "intValue", "doubleValue", "arrayValue", "kvlistValue", "bytesValue" +]; + +/** + * Creates a result collector. + */ +function createResults() { + return { + passed: [], + failed: [], + check(condition, description, detail) { + if (condition) { + this.passed.push(description); + } else { + this.failed.push({ description, detail: detail === undefined ? null : detail }); + } + + return !!condition; + } + }; +} + +function isPlainObject(value) { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * Validates a single OTLP `AnyValue`, which is a `oneof` so exactly one member must be set. + */ +function validateAnyValue(results, value, path) { + if (!isPlainObject(value)) { + results.check(false, path + " is an object", value); + return; + } + + const keys = Object.keys(value); + // An AnyValue with no members represents an empty value which is legal + results.check(keys.length <= 1, path + " sets at most one AnyValue member", keys); + + keys.forEach((key) => { + results.check(ANY_VALUE_MEMBERS.indexOf(key) !== -1, path + " uses a known AnyValue member", key); + }); + + if (typeof value.intValue !== "undefined") { + results.check(typeof value.intValue === "string" && DIGITS.test(value.intValue.replace("-", "")), + path + ".intValue is an integer encoded as a string", value.intValue); + } + + if (typeof value.doubleValue !== "undefined") { + results.check(typeof value.doubleValue === "number" && isFinite(value.doubleValue), + path + ".doubleValue is a finite number", value.doubleValue); + } + + if (typeof value.stringValue !== "undefined") { + results.check(typeof value.stringValue === "string", path + ".stringValue is a string", value.stringValue); + } + + if (value.arrayValue) { + results.check(Array.isArray(value.arrayValue.values), path + ".arrayValue.values is an array"); + (value.arrayValue.values || []).forEach((entry, idx) => { + validateAnyValue(results, entry, path + ".arrayValue[" + idx + "]"); + }); + } +} + +/** + * Validates an OTLP attribute list, including that no key is duplicated (a duplicate key has + * undefined behaviour in OTLP). + */ +function validateAttributes(results, attributes, path) { + if (typeof attributes === "undefined") { + return {}; + } + + if (!results.check(Array.isArray(attributes), path + " is an array", attributes)) { + return {}; + } + + const seen = {}; + const map = {}; + + attributes.forEach((attr, idx) => { + const attrPath = path + "[" + idx + "]"; + if (!results.check(isPlainObject(attr) && typeof attr.key === "string" && attr.key.length > 0, + attrPath + " has a non empty string key", attr)) { + return; + } + + results.check(!seen[attr.key], path + " does not repeat the key '" + attr.key + "'"); + seen[attr.key] = true; + + validateAnyValue(results, attr.value, attrPath + "(" + attr.key + ").value"); + map[attr.key] = attr.value; + }); + + return map; +} + +/** + * Validates an OTLP `timeUnixNano` value, which must be an integer number of nanoseconds since the + * unix epoch encoded as a decimal string. + */ +function validateUnixNano(results, value, path, options) { + options = options || {}; + + if (!results.check(typeof value === "string", path + " is a string (int64 values must not be JSON numbers)", value)) { + return null; + } + + if (!results.check(DIGITS.test(value), path + " contains only digits", value)) { + return null; + } + + // A current timestamp in nanoseconds has 19 digits. This is the assertion that catches the + // precision loss that occurs if the value is ever computed using JavaScript number arithmetic. + results.check(value.length === 19, path + " has nanosecond resolution (19 digits)", value); + + const millis = Number(value.substring(0, 13)); + const now = Date.now(); + const dayMs = 24 * 60 * 60 * 1000; + if (!options.allowAnyTime) { + results.check(millis > now - dayMs && millis < now + dayMs, + path + " is within a day of now", new Date(millis).toISOString()); + } + + return value; +} + +/** + * Compares two `timeUnixNano` decimal strings. + */ +function compareUnixNano(a, b) { + if (a.length !== b.length) { + return a.length - b.length; + } + + return a < b ? -1 : (a > b ? 1 : 0); +} + +function validateResource(results, resource, path) { + if (!results.check(isPlainObject(resource), path + " is present", resource)) { + return {}; + } + + const attrs = validateAttributes(results, resource.attributes, path + ".attributes"); + + results.check(!!attrs["service.name"], path + " declares service.name"); + results.check(!!attrs["telemetry.sdk.name"], path + " declares telemetry.sdk.name"); + results.check(attrs["telemetry.sdk.language"] && attrs["telemetry.sdk.language"].stringValue === "webjs", + path + " declares telemetry.sdk.language of webjs", attrs["telemetry.sdk.language"]); + results.check(!!attrs["telemetry.sdk.version"], path + " declares telemetry.sdk.version"); + + // The instrumentation key must never leak into the resource unless it was explicitly opted into + results.check(!attrs["microsoft.instrumentation_key"], + path + " does not include the instrumentation key by default"); + + return attrs; +} + +function validateScope(results, scope, path) { + if (!results.check(isPlainObject(scope), path + " is present", scope)) { + return; + } + + results.check(typeof scope.name === "string" && scope.name.length > 0, path + ".name is set", scope.name); +} + +/** + * Validates a single OTLP span. + */ +function validateSpan(results, span, path) { + results.check(HEX_32.test(span.traceId || ""), path + ".traceId is 32 lowercase hex characters", span.traceId); + results.check(HEX_16.test(span.spanId || ""), path + ".spanId is 16 lowercase hex characters", span.spanId); + + if (typeof span.parentSpanId !== "undefined") { + results.check(HEX_16.test(span.parentSpanId), path + ".parentSpanId is 16 lowercase hex characters", + span.parentSpanId); + results.check(span.parentSpanId !== span.spanId, path + " is not its own parent", span.spanId); + } + + results.check(typeof span.name === "string" && span.name.length > 0, path + ".name is set", span.name); + + // The canonical proto3 JSON encoding omits fields that hold their default value, so a collector's + // own re-serialization drops `kind: 0` and `status.code: 0`. An absent value therefore means the + // default rather than a missing field. + const kind = typeof span.kind === "undefined" ? 0 : span.kind; + results.check([0, 1, 2, 3, 4, 5].indexOf(kind) !== -1, path + ".kind is a valid SpanKind", span.kind); + + const start = validateUnixNano(results, span.startTimeUnixNano, path + ".startTimeUnixNano"); + const end = validateUnixNano(results, span.endTimeUnixNano, path + ".endTimeUnixNano"); + + if (start && end) { + results.check(compareUnixNano(end, start) >= 0, path + " ends at or after it starts", + { start, end }); + } + + if (typeof span.status !== "undefined") { + const statusCode = typeof span.status.code === "undefined" ? 0 : span.status.code; + results.check([0, 1, 2].indexOf(statusCode) !== -1, path + ".status.code is a valid StatusCode", + span.status.code); + } + + const attrs = validateAttributes(results, span.attributes, path + ".attributes"); + + // Semantic convention correctness. These cannot be caught by shape checks alone, and are exactly + // the kind of thing that looks well formed while being wrong. + const serverAddress = attrs["server.address"] && attrs["server.address"].stringValue; + if (serverAddress) { + results.check(serverAddress.indexOf("://") === -1 && serverAddress.indexOf("/") === -1, + path + ".server.address is a host rather than a url", serverAddress); + results.check(serverAddress.indexOf(":") === -1, + path + ".server.address does not embed the port (server.port is used for that)", serverAddress); + } + + const peerService = attrs["peer.service"] && attrs["peer.service"].stringValue; + if (peerService) { + results.check(peerService.indexOf("://") === -1, path + ".peer.service is a host rather than a url", + peerService); + } + + const urlFull = attrs["url.full"] && attrs["url.full"].stringValue; + if (urlFull) { + results.check(/^[a-z][a-z0-9+.-]*:\/\//i.test(urlFull), path + ".url.full is an absolute url", urlFull); + } + + if (attrs["server.port"]) { + results.check(typeof attrs["server.port"].intValue === "string", + path + ".server.port is an integer", attrs["server.port"]); + } + + // A hierarchical Application Insights id of the form "|." carries a recoverable + // span id, so seeing one preserved as a fallback attribute means the real span id was discarded + // and the parent/child relationships in the trace are broken. + const preservedId = attrs["microsoft.telemetry_id"] && attrs["microsoft.telemetry_id"].stringValue; + if (preservedId) { + results.check(!/^\|[0-9a-f]{32}\.[0-9a-f]{16}/.test(preservedId), + path + " did not discard a recoverable span id", preservedId); + } + + return attrs; +} + +/** + * Validates a single OTLP log record. + */ +function validateLogRecord(results, record, path) { + validateUnixNano(results, record.timeUnixNano, path + ".timeUnixNano"); + validateUnixNano(results, record.observedTimeUnixNano, path + ".observedTimeUnixNano"); + + // As above, the canonical encoding omits a severityNumber of 0 + const severityNumber = typeof record.severityNumber === "undefined" ? 0 : record.severityNumber; + results.check([0, 1, 5, 9, 13, 17, 21].indexOf(severityNumber) !== -1, + path + ".severityNumber is a known severity", record.severityNumber); + results.check(typeof record.severityText === "string" && record.severityText.length > 0, + path + ".severityText is set", record.severityText); + + if (typeof record.traceId !== "undefined") { + results.check(HEX_32.test(record.traceId), path + ".traceId is 32 lowercase hex characters", record.traceId); + } + + if (typeof record.spanId !== "undefined") { + results.check(HEX_16.test(record.spanId), path + ".spanId is 16 lowercase hex characters", record.spanId); + } + + if (typeof record.body !== "undefined") { + validateAnyValue(results, record.body, path + ".body"); + } + + return validateAttributes(results, record.attributes, path + ".attributes"); +} + +/** + * Walks every request captured by the mock collector and validates the OTLP structure. + * @param requests - The captured requests, each `{ signal, url, body }`. + * @returns A validation report. + */ +function validatePayloads(requests) { + const results = createResults(); + const summary = { + requests: requests.length, + spans: 0, + logs: 0, + services: {}, + spanNames: {}, + spanKinds: {}, + spanStatuses: {}, + spansWithHttpMethod: 0, + spansWithUrl: 0, + telemetryTypes: {}, + markersByService: {} + }; + + results.check(requests.length > 0, "The collector received at least one OTLP request"); + + requests.forEach((request, requestIdx) => { + const path = "request[" + requestIdx + "]"; + const body = request.body; + + if (!results.check(isPlainObject(body), path + " has a JSON object body")) { + return; + } + + const isTrace = request.signal === "traces"; + results.check(request.url.indexOf(isTrace ? "/v1/traces" : "/v1/logs") !== -1, + path + " was posted to the correct signal endpoint", request.url); + results.check((request.contentType || "").indexOf("application/json") === 0, + path + " declared a JSON content type", request.contentType); + + const resourceKey = isTrace ? "resourceSpans" : "resourceLogs"; + const scopeKey = isTrace ? "scopeSpans" : "scopeLogs"; + const recordKey = isTrace ? "spans" : "logRecords"; + + const bodyKeys = Object.keys(body); + results.check(bodyKeys.length === 1 && bodyKeys[0] === resourceKey, + path + " uses only the " + resourceKey + " envelope", bodyKeys); + + if (!Array.isArray(body[resourceKey])) { + results.check(false, path + "." + resourceKey + " is an array", body[resourceKey]); + return; + } + + body[resourceKey].forEach((resourceEntry, resourceIdx) => { + const resourcePath = path + "." + resourceKey + "[" + resourceIdx + "]"; + const resourceAttrs = validateResource(results, resourceEntry.resource, resourcePath + ".resource"); + + const serviceName = resourceAttrs["service.name"] && resourceAttrs["service.name"].stringValue; + const marker = resourceAttrs["test.instance.marker"] && resourceAttrs["test.instance.marker"].stringValue; + if (serviceName) { + summary.services[serviceName] = (summary.services[serviceName] || 0) + 1; + } + + if (!Array.isArray(resourceEntry[scopeKey])) { + results.check(false, resourcePath + "." + scopeKey + " is an array", resourceEntry[scopeKey]); + return; + } + + resourceEntry[scopeKey].forEach((scopeEntry, scopeIdx) => { + const scopePath = resourcePath + "." + scopeKey + "[" + scopeIdx + "]"; + validateScope(results, scopeEntry.scope, scopePath + ".scope"); + + const records = scopeEntry[recordKey]; + if (!Array.isArray(records)) { + results.check(false, scopePath + "." + recordKey + " is an array", records); + return; + } + + records.forEach((record, recordIdx) => { + const recordPath = scopePath + "." + recordKey + "[" + recordIdx + "]"; + const attrs = isTrace + ? validateSpan(results, record, recordPath) + : validateLogRecord(results, record, recordPath); + + if (isTrace) { + summary.spans++; + summary.spanNames[record.name] = (summary.spanNames[record.name] || 0) + 1; + + // Normalize the omitted-default encoding so the counts are comparable whether + // the payload came from the channel or from a collector's re-serialization + const kind = typeof record.kind === "undefined" ? 0 : record.kind; + const statusCode = record.status && typeof record.status.code !== "undefined" + ? record.status.code : 0; + + summary.spanKinds[kind] = (summary.spanKinds[kind] || 0) + 1; + summary.spanStatuses[statusCode] = (summary.spanStatuses[statusCode] || 0) + 1; + + if (attrs["http.request.method"]) { + summary.spansWithHttpMethod++; + } + if (attrs["url.full"]) { + summary.spansWithUrl++; + } + } else { + summary.logs++; + } + + const telemetryType = attrs["microsoft.telemetry_type"] && + attrs["microsoft.telemetry_type"].stringValue; + if (telemetryType) { + summary.telemetryTypes[telemetryType] = (summary.telemetryTypes[telemetryType] || 0) + 1; + } + + // Cross instance isolation: a record must never carry a marker belonging to a + // different instance than the resource it was exported under. + const recordMarker = attrs["test.marker"] && attrs["test.marker"].stringValue; + if (marker && recordMarker) { + results.check(recordMarker === marker, + recordPath + " carries the marker of its own instance", + { resourceMarker: marker, recordMarker }); + } + + if (serviceName && recordMarker) { + const bucket = summary.markersByService[serviceName] || + (summary.markersByService[serviceName] = {}); + bucket[recordMarker] = (bucket[recordMarker] || 0) + 1; + } + }); + }); + }); + }); + + return { results, summary }; +} + +/** + * Applies the expectations that describe a complete, correct run of the example site. + */ +function validateExpectations(requests, options) { + options = options || {}; + const { results, summary } = validatePayloads(requests); + + const expectedServices = options.expectedServices || ["storefront-web", "checkout-widget"]; + expectedServices.forEach((service) => { + results.check(!!summary.services[service], "Telemetry was received for the '" + service + "' service"); + }); + + // Each service must only ever carry its own marker + Object.keys(summary.markersByService).forEach((service) => { + const markers = Object.keys(summary.markersByService[service]); + results.check(markers.length === 1, + "The '" + service + "' service only carries telemetry from a single instance", markers); + }); + + results.check(Object.keys(summary.markersByService).length >= expectedServices.length || + Object.keys(summary.services).length >= expectedServices.length, + "Both instances exported telemetry independently", summary.services); + + results.check(summary.spans > 0, "At least one span was exported"); + results.check(summary.logs > 0, "At least one log record was exported"); + + // Span coverage: the example produces page views and manual spans (INTERNAL) as well as + // automatically collected fetch / XHR dependencies (CLIENT). + results.check(summary.spanKinds[1] > 0, "At least one INTERNAL span was exported (page view / manual span)", + summary.spanKinds); + results.check(summary.spanKinds[3] > 0, "At least one CLIENT span was exported (auto collected dependency)", + summary.spanKinds); + results.check(summary.spansWithHttpMethod > 0, "At least one span carries the http.request.method attribute"); + results.check(summary.spansWithUrl > 0, "At least one span carries the url.full attribute"); + results.check(summary.spanStatuses[2] > 0, + "At least one span reports an ERROR status (the deliberately failing request)", summary.spanStatuses); + results.check(summary.spanStatuses[1] > 0, "At least one span reports an OK status", summary.spanStatuses); + + const expectedTypes = options.expectedTelemetryTypes || + ["MessageData", "ExceptionData", "EventData", "MetricData"]; + expectedTypes.forEach((type) => { + results.check(!!summary.telemetryTypes[type], + "A log record was exported for " + type, Object.keys(summary.telemetryTypes)); + }); + + return { results, summary }; +} + +/** + * Runs the full validation and returns a plain report object. + */ +function validate(requests, options) { + const { results, summary } = validateExpectations(requests, options); + + return { + ok: results.failed.length === 0, + passedCount: results.passed.length, + failedCount: results.failed.length, + failures: results.failed, + summary + }; +} + +module.exports = { + validate, + validatePayloads, + validateExpectations, + compareUnixNano +}; diff --git a/examples/otlp/tools/verify-with-collector.js b/examples/otlp/tools/verify-with-collector.js new file mode 100644 index 000000000..2644d58c7 --- /dev/null +++ b/examples/otlp/tools/verify-with-collector.js @@ -0,0 +1,277 @@ +/* + * Validates the OTLP channel output against a REAL OpenTelemetry Collector. + * + * The browser exports to the collector, which parses the payload with the real protocol + * implementation -- anything malformed is rejected with a 4xx rather than quietly accepted. What the + * collector successfully parsed is then re-exported as canonical OTLP/JSON back to the example's + * mock collector, where the same validation rules are applied to the collector's own + * re-serialization. + * + * browser -> real collector (:4318) -> mock collector (:8099) -> validator + * + * Usage: + * node tools/verify-with-collector.js # starts the local otelcol binary + * node tools/verify-with-collector.js --external # a collector is already running on 4318 + */ +const path = require("path"); +const fs = require("fs"); +const http = require("http"); +const { spawn } = require("child_process"); +const { validate } = require("./validate"); + +const PORT = Number(process.env.OTLP_EXAMPLE_PORT || 8099); +const BASE = "http://localhost:" + PORT; +const COLLECTOR = process.env.OTLP_COLLECTOR_URL || "http://localhost:4318"; +const PAGES = ["index.html", "products.html", "checkout.html"]; + +const collectorDir = path.resolve(__dirname, "../collector"); +const collectorExe = path.join(collectorDir, "bin", process.platform === "win32" ? "otelcol.exe" : "otelcol"); +const collectorConfig = path.join(collectorDir, "otel-collector-config.yaml"); + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +function httpGet(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + try { + resolve({ status: res.statusCode, body: JSON.parse(body) }); + } catch (e) { + resolve({ status: res.statusCode, body }); + } + }); + }).on("error", reject); + }); +} + +function postEmpty(url) { + return new Promise((resolve, reject) => { + const req = http.request(url, { method: "POST" }, resolve); + req.on("error", reject); + req.end(); + }); +} + +async function waitFor(url, timeoutMs, label) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await httpGet(url); + return true; + } catch (e) { + await delay(300); + } + } + + throw new Error("Timed out waiting for " + label + " at " + url); +} + +/** + * The collector has no unauthenticated health endpoint by default, so readiness is probed by POSTing + * an empty OTLP request to it. A 200 means the receiver is up and parsing. + */ +function probeCollector() { + return new Promise((resolve) => { + const body = JSON.stringify({ resourceSpans: [] }); + const req = http.request(COLLECTOR + "/v1/traces", { + method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } + }, (res) => { + res.resume(); + resolve(res.statusCode); + }); + req.on("error", () => resolve(0)); + req.end(body); + }); +} + +async function waitForCollector(timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const status = await probeCollector(); + if (status >= 200 && status < 300) { + return true; + } + await delay(400); + } + + return false; +} + +async function run() { + const external = process.argv.indexOf("--external") !== -1; + const failures = []; + const rejected = []; + let collector = null; + let server = null; + let browser = null; + let collectorOutput = []; + + try { + const puppeteer = require("puppeteer"); + + // ---- the example's own server, which both serves the site and receives the round trip ---- + let serverAlreadyRunning = false; + try { + await httpGet(BASE + "/__collected"); + serverAlreadyRunning = true; + console.log("Note: reusing the server already listening on " + BASE); + } catch (e) { + serverAlreadyRunning = false; + } + + if (!serverAlreadyRunning) { + server = spawn(process.execPath, [path.join(__dirname, "server.js")], { + stdio: ["ignore", "ignore", "pipe"], + env: Object.assign({}, process.env, { OTLP_EXAMPLE_PORT: String(PORT) }) + }); + server.stderr.on("data", (d) => console.error("[server] " + d.toString().trim())); + await waitFor(BASE + "/__collected", 15000, "the example server"); + } + + await postEmpty(BASE + "/__reset"); + + // ---- the real OpenTelemetry Collector ---- + if (!external) { + if (!fs.existsSync(collectorExe)) { + console.error("The collector binary was not found at " + collectorExe); + console.error("Run collector/get-collector.ps1 (or .sh), or start one yourself and"); + console.error("re-run with --external."); + process.exit(2); + } + + console.log("Starting the OpenTelemetry Collector ..."); + collector = spawn(collectorExe, ["--config", collectorConfig], { + cwd: collectorDir, + stdio: ["ignore", "pipe", "pipe"] + }); + + const collectorLog = []; + const noteOutput = (text) => { + collectorLog.push(text); + // The collector logs with tab separated fields, so the level is matched precisely + // rather than by looking for the word "error" anywhere in the debug output. + if (/\terror\t/.test(text) || /Permanent error/.test(text) || + /Exporting failed/.test(text) || /Dropping data/.test(text)) { + rejected.push(text.trim()); + } + }; + + collector.stdout.on("data", (d) => noteOutput(d.toString())); + collector.stderr.on("data", (d) => noteOutput(d.toString())); + collectorOutput = collectorLog; + } + + if (!(await waitForCollector(external ? 15000 : 40000))) { + throw new Error("The OpenTelemetry Collector did not become ready on " + COLLECTOR); + } + console.log("Collector is accepting OTLP on " + COLLECTOR); + console.log(""); + + // ---- drive the site, exporting to the real collector ---- + browser = await puppeteer.launch({ + headless: process.argv.indexOf("--headful") === -1 ? "new" : false, + args: ["--no-sandbox", "--disable-dev-shm-usage"] + }); + + for (const pageName of PAGES) { + const page = await browser.newPage(); + const badResponses = []; + + page.on("response", (response) => { + const status = response.status(); + const url = response.url(); + if (status >= 400 && url.indexOf("/api/missing") === -1) { + badResponses.push(status + " " + url); + } + }); + + const target = BASE + "/" + pageName + "?autorun&collector=" + encodeURIComponent(COLLECTOR); + await page.goto(target, { waitUntil: "load" }); + await page.waitForFunction("window.__otlpAutoRunComplete === true", { timeout: 30000 }); + await delay(2500); + + if (badResponses.length) { + failures.push("[" + pageName + "] the collector rejected a request: " + badResponses.join(", ")); + } + + console.log(" " + pageName + " exported to the real collector"); + await page.close(); + } + + // Let the collector's batch processor flush and the round trip arrive + await delay(5000); + + // ---- validate what the real collector re-exported ---- + const collected = await httpGet(BASE + "/__collected"); + const requests = collected.body.requests; + + if (!requests.length) { + throw new Error("The collector did not re-export anything to the example server. " + + "Check that the collector config points otlphttp/roundtrip at " + BASE); + } + + const report = validate(requests); + + console.log(""); + console.log("=== Validation of the REAL collector's own re-serialization ==="); + console.log("Round tripped requests : " + requests.length); + console.log("Spans : " + report.summary.spans); + console.log("Log records : " + report.summary.logs); + console.log("Services : " + JSON.stringify(report.summary.services)); + console.log("Span kinds : " + JSON.stringify(report.summary.spanKinds)); + console.log("Telemetry types : " + JSON.stringify(report.summary.telemetryTypes)); + console.log("Assertions : " + report.passedCount + " passed, " + + report.failedCount + " failed"); + + report.failures.forEach((f) => { + failures.push("[round trip] " + f.description + + (f.detail === null ? "" : " -- " + JSON.stringify(f.detail))); + }); + + if (rejected.length) { + failures.push("[collector] reported errors: " + rejected.slice(0, 5).join(" | ")); + } + + // Show a slice of what the collector itself parsed, as direct evidence that a real OTLP + // implementation understood the payload rather than merely accepting the bytes. + const parsedSample = collectorOutput.join("").split("\n") + .filter((line) => /^(Span|ResourceSpans|Resource attributes|LogRecord|\s+->|Trace ID|Span ID|Name\s*:|Kind\s*:|Status code)/.test(line.trim()) || + /^\s+-> (service\.name|url\.full|server\.address|server\.port|http\.request\.method)/.test(line)) + .slice(0, 24); + + if (parsedSample.length) { + console.log(""); + console.log("=== What the collector itself parsed (debug exporter) ==="); + parsedSample.forEach((line) => console.log(" " + line.trim())); + } + } catch (e) { + failures.push("[harness] " + e.message); + } finally { + if (browser) { + await browser.close(); + } + if (collector) { + collector.kill(); + } + if (server) { + server.kill(); + } + } + + console.log(""); + if (failures.length) { + console.error("FAILED (" + failures.length + " issue(s)):"); + failures.forEach((f) => console.error(" - " + f)); + process.exit(1); + } + + console.log("PASSED - a real OpenTelemetry Collector accepted every payload, and its own"); + console.log(" re-serialization of the data satisfies every validation rule."); + process.exit(0); +} + +run(); diff --git a/gruntfile.js b/gruntfile.js index 3bc71056e..33f3bc35b 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -544,6 +544,7 @@ module.exports = function (grunt) { path: "./channels/offline-channel-js" }, "teechannel": { path: "./channels/tee-channel-js" }, + "otlpchannel": { path: "./channels/otlp-channel-js" }, "1dsPost": { path: "./channels/1ds-post-js", unitTestName: "post.unittests.js" @@ -883,6 +884,7 @@ module.exports = function (grunt) { "aichannel": "./channels/applicationinsights-channel-js", "offlinechannel": "./channels/offline-channel-js", "teechannel": "./channels/tee-channel-js", + "otlpchannel": "./channels/otlp-channel-js", "1dsPost": "./channels/1ds-post-js", "clickanalytics": "./extensions/applicationinsights-clickanalytics-js", "cfgsync": "./extensions/applicationinsights-cfgsync-js", @@ -938,7 +940,7 @@ module.exports = function (grunt) { } let packages = [ "core", "common", "appinsights", "aisku", "aiskulite", "perfmarkmeasure", "properties", - "cfgsync", "deps", "debugplugin", "aichannel", "offlinechannel", "teechannel", + "cfgsync", "deps", "debugplugin", "aichannel", "offlinechannel", "teechannel", "otlpchannel", "1dsCore", "1dsPost", "rollupuglify", "rollupes5", "shims", "chrome-debug-extension", "applicationinsights-web-snippet", "clickanalytics", "osplugin" ]; @@ -1048,6 +1050,12 @@ module.exports = function (grunt) { grunt.registerTask("teechanneltest", tsTestActions("teechannel")); grunt.registerTask("teechannel-mintest", tsTestActions("teechannel", true)); + grunt.registerTask("otlpchannel", tsBuildActions("otlpchannel")); + grunt.registerTask("otlpchannel-min", minTasks("otlpchannel")); + grunt.registerTask("otlpchannel-restore", restoreTasks("otlpchannel")); + grunt.registerTask("otlpchanneltest", tsTestActions("otlpchannel")); + grunt.registerTask("otlpchannel-mintest", tsTestActions("otlpchannel", true)); + grunt.registerTask("rollupuglify", tsBuildActions("rollupuglify")); grunt.registerTask("rollupes5", tsBuildActions("rollupes5")); grunt.registerTask("rollupes5test", tsTestActions("rollupes5", false)); diff --git a/rush.json b/rush.json index 4309dc782..6f3e18183 100644 --- a/rush.json +++ b/rush.json @@ -99,6 +99,11 @@ "projectFolder": "channels/tee-channel-js", "shouldPublish": true }, + { + "packageName": "@microsoft/applicationinsights-otlpchannel-js", + "projectFolder": "channels/otlp-channel-js", + "shouldPublish": true + }, { "packageName": "@microsoft/applicationinsights-web", "projectFolder": "AISKU", diff --git a/tools/release-tools/package_groups.json b/tools/release-tools/package_groups.json index 5ff4e3444..ea50e40fa 100644 --- a/tools/release-tools/package_groups.json +++ b/tools/release-tools/package_groups.json @@ -20,7 +20,8 @@ "./channels/tee-channel-js", "./shared/1ds-core-js", "./channels/1ds-post-js", - "./channels/offline-channel-js" + "./channels/offline-channel-js", + "./channels/otlp-channel-js" ], "1ds": [ "./shared/1ds-core-js", @@ -41,7 +42,8 @@ "./extensions/applicationinsights-debugplugin-js", "./extensions/applicationinsights-cfgsync-js", "./channels/tee-channel-js", - "./channels/offline-channel-js" + "./channels/offline-channel-js", + "./channels/otlp-channel-js" ], "cfgSync": [ "./extensions/applicationinsights-cfgsync-js" diff --git a/version.json b/version.json index 38485c8bb..1162995de 100644 --- a/version.json +++ b/version.json @@ -56,6 +56,10 @@ "package": "channels/offline-channel-js/package.json", "release": "0.4.3" }, + "@microsoft/applicationinsights-otlpchannel-js": { + "package": "channels/otlp-channel-js/package.json", + "release": "0.1.0" + }, "@microsoft/applicationinsights-chrome-debug-extension": { "package": "tools/chrome-debug-extension/package.json", "release": "0.9.3"