Skip to content

Commit

Permalink
Track request body size in XHR and Fetch instrumentations (open-telem…
Browse files Browse the repository at this point in the history
…etry#4706)

Co-authored-by: Jamie Danielson <[email protected]>
  • Loading branch information
MustafaHaddara and JamieDanielson authored Nov 14, 2024
1 parent 56a0308 commit c78a02f
Show file tree
Hide file tree
Showing 12 changed files with 2,317 additions and 84 deletions.
1 change: 1 addition & 0 deletions experimental/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ All notable changes to experimental packages in this project will be documented
* feat(sdk-node, sdk-logs): add `mergeResourceWithDefaults` flag, which allows opting-out of resources getting merged with the default resource [#4617](https://github.com/open-telemetry/opentelemetry-js/pull/4617)
* default: `true`
* note: `false` will become the default behavior in a future iteration in order to comply with [specification requirements](https://github.com/open-telemetry/opentelemetry-specification/blob/f3511a5ccda376dfd1de76dfa086fc9b35b54757/specification/resource/sdk.md?plain=1#L31-L36)
* feat(instrumentation): Track request body size in XHR and Fetch instrumentations [#4706](https://github.com/open-telemetry/opentelemetry-js/pull/4706) @mustafahaddara

### :bug: (Bug Fix)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ Fetch instrumentation plugin has few options available to choose from. You can s

| Options | Type | Description |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------|-----------------------------------------------------------------------------------------|
| [`applyCustomAttributesOnSpan`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L64) | `HttpCustomAttributeFunction` | Function for adding custom attributes |
| [`ignoreNetworkEvents`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L67) | `boolean` | Disable network events being added as span events (network events are added by default) |
| [`applyCustomAttributesOnSpan`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L75) | `HttpCustomAttributeFunction` | Function for adding custom attributes |
| [`ignoreNetworkEvents`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L77) | `boolean` | Disable network events being added as span events (network events are added by default) |
| [`measureRequestSize`](https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/packages/opentelemetry-instrumentation-fetch/src/fetch.ts#L79) | `boolean` | Measure outgoing request length (outgoing request length is not measured by default) |

## Semantic Conventions

Expand All @@ -79,12 +80,13 @@ Attributes collected:

| Attribute | Short Description |
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `http.status_code` | HTTP response status code |
| `http.status_code` | HTTP response status code |
| `http.host` | The value of the HTTP host header |
| `http.user_agent` | Value of the HTTP User-Agent header sent by the client |
| `http.scheme` | The URI scheme identifying the used protocol |
| `http.url` | Full HTTP request URL |
| `http.method` | HTTP request method |
| `http.request_content_length_uncompressed` | Uncompressed size of the request body, if any body exists |

## Useful links

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import {
SEMATTRS_HTTP_SCHEME,
SEMATTRS_HTTP_URL,
SEMATTRS_HTTP_METHOD,
SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED,
} from '@opentelemetry/semantic-conventions';
import { FetchError, FetchResponse, SpanData } from './types';
import { getFetchBodyLength } from './utils';
import { VERSION } from './version';
import { _globalThis } from '@opentelemetry/core';

Expand Down Expand Up @@ -74,6 +76,8 @@ export interface FetchInstrumentationConfig extends InstrumentationConfig {
applyCustomAttributesOnSpan?: FetchCustomAttributeFunction;
// Ignore adding network events as span events
ignoreNetworkEvents?: boolean;
/** Measure outgoing request size */
measureRequestSize?: boolean;
}

/**
Expand Down Expand Up @@ -320,6 +324,21 @@ export class FetchInstrumentation extends InstrumentationBase<FetchInstrumentati
}
const spanData = plugin._prepareSpanData(url);

if (plugin.getConfig().measureRequestSize) {
getFetchBodyLength(...args)
.then(length => {
if (!length) return;

createdSpan.setAttribute(
SEMATTRS_HTTP_REQUEST_CONTENT_LENGTH_UNCOMPRESSED,
length
);
})
.catch(error => {
plugin._diag.warn('getFetchBodyLength', error);
});
}

function endSpanOnError(span: api.Span, error: FetchError) {
plugin._applyAttributesAfterFetch(span, options, error);
plugin._endSpan(span, spanData, {
Expand Down
173 changes: 173 additions & 0 deletions experimental/packages/opentelemetry-instrumentation-fetch/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* Copyright The OpenTelemetry Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Much of the logic here overlaps with the same utils file in opentelemetry-instrumentation-xml-http-request
// These may be unified in the future.

import * as api from '@opentelemetry/api';

const DIAG_LOGGER = api.diag.createComponentLogger({
namespace: '@opentelemetry/opentelemetry-instrumentation-fetch/utils',
});

/**
* Helper function to determine payload content length for fetch requests
*
* The fetch API is kinda messy: there are a couple of ways the body can be passed in.
*
* In all cases, the body param can be some variation of ReadableStream,
* and ReadableStreams can only be read once! We want to avoid consuming the body here,
* because that would mean that the body never gets sent with the actual fetch request.
*
* Either the first arg is a Request object, which can be cloned
* so we can clone that object and read the body of the clone
* without disturbing the original argument
* However, reading the body here can only be done async; the body() method returns a promise
* this means this entire function has to return a promise
*
* OR the first arg is a url/string
* in which case the second arg has type RequestInit
* RequestInit is NOT cloneable, but RequestInit.body is writable
* so we can chain it into ReadableStream.pipeThrough()
*
* ReadableStream.pipeThrough() lets us process a stream and returns a new stream
* So we can measure the body length as it passes through the pie, but need to attach
* the new stream to the original request
* so that the browser still has access to the body.
*
* @param body
* @returns promise that resolves to the content length of the body
*/
export function getFetchBodyLength(...args: Parameters<typeof fetch>) {
if (args[0] instanceof URL || typeof args[0] === 'string') {
const requestInit = args[1];
if (!requestInit?.body) {
return Promise.resolve();
}
if (requestInit.body instanceof ReadableStream) {
const { body, length } = _getBodyNonDestructively(requestInit.body);
requestInit.body = body;

return length;
} else {
return Promise.resolve(getXHRBodyLength(requestInit.body));
}
} else {
const info = args[0];
if (!info?.body) {
return Promise.resolve();
}

return info
.clone()
.text()
.then(t => getByteLength(t));
}
}

function _getBodyNonDestructively(body: ReadableStream) {
// can't read a ReadableStream without destroying it
// but we CAN pipe it through and return a new ReadableStream

// some (older) platforms don't expose the pipeThrough method and in that scenario, we're out of luck;
// there's no way to read the stream without consuming it.
if (!body.pipeThrough) {
DIAG_LOGGER.warn('Platform has ReadableStream but not pipeThrough!');
return {
body,
length: Promise.resolve(undefined),
};
}

let length = 0;
let resolveLength: (l: number) => void;
const lengthPromise = new Promise<number>(resolve => {
resolveLength = resolve;
});

const transform = new TransformStream({
start() {},
async transform(chunk, controller) {
const bytearray = (await chunk) as Uint8Array;
length += bytearray.byteLength;

controller.enqueue(chunk);
},
flush() {
resolveLength(length);
},
});

return {
body: body.pipeThrough(transform),
length: lengthPromise,
};
}

/**
* Helper function to determine payload content length for XHR requests
* @param body
* @returns content length
*/
export function getXHRBodyLength(
body: Document | XMLHttpRequestBodyInit
): number | undefined {
if (typeof Document !== 'undefined' && body instanceof Document) {
return new XMLSerializer().serializeToString(document).length;
}
// XMLHttpRequestBodyInit expands to the following:
if (body instanceof Blob) {
return body.size;
}

// ArrayBuffer | ArrayBufferView
if ((body as any).byteLength !== undefined) {
return (body as any).byteLength as number;
}

if (body instanceof FormData) {
return getFormDataSize(body);
}

if (body instanceof URLSearchParams) {
return getByteLength(body.toString());
}

if (typeof body === 'string') {
return getByteLength(body);
}

DIAG_LOGGER.warn('unknown body type');
return undefined;
}

const TEXT_ENCODER = new TextEncoder();
function getByteLength(s: string): number {
return TEXT_ENCODER.encode(s).byteLength;
}

function getFormDataSize(formData: FormData): number {
let size = 0;
for (const [key, value] of formData.entries()) {
size += key.length;
if (value instanceof Blob) {
size += value.size;
} else {
size += value.length;
}
}
return size;
}
Loading

0 comments on commit c78a02f

Please sign in to comment.