Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Capture request, response headers and body #9

Merged
merged 5 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/purple-monkeys-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/otel-react-native': patch
---

feat: Capture request headers, request body and response body
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ HyperDXRum.init({
apiKey: '<YOUR_API_KEY_HERE>',
tracePropagationTargets: [/api.myapp.domain/i], // Set to link traces from frontend to backend requests
networkHeadersCapture: false,
networkBodyCapture: false,
});
```

Expand All @@ -39,7 +40,8 @@ HyperDXRum.init({
- `apiKey`: Your HyperDX Ingestion API key. You can find it [here](https://www.hyperdx.io/team).
- `service`: Name of your application. Set it to distinguish your app from others in HyperDX.
- `tracePropagationTargets`: A list of regular expressions that match the URLs of your backend services. Set it to link traces from frontend to backend requests.
- `networkHeadersCapture`: networkHeadersCapture is a flag that allows the option to capture header information, with the default flag being false.
- `networkHeadersCapture`: networkHeadersCapture is a flag that allows the option to capture request and response headers information, with the default flag being false.
- `networkBodyCapture`: networkBodyCapture is a flag that allows the option to capture request and response body information, with the default flag being false.

### (Optional) Attach User Information or Metadata

Expand Down
1 change: 0 additions & 1 deletion src/globalAttributeAppender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export default class GlobalAttributeAppender implements SpanProcessor {

onStart(span: Span): void {
span.setAttributes(getGlobalAttributes());
span.setAttribute('_hyperdx_operation', span.name);
}
shutdown(): Promise<void> {
return Promise.resolve();
Expand Down
101 changes: 86 additions & 15 deletions src/instrumentations/xhr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,14 @@ import type { PropagateTraceHeaderCorsUrls } from '@opentelemetry/sdk-trace-web/

const parseUrl = (url: string) => new URL(url);

const MAX_BODY_LENGTH = 5 * 1024; // 5KB
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5k seems a bit too strict. maybe something like 2MB to start with is okay

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated


interface XhrConfig {
clearTimingResources?: boolean;
ignoreUrls: Array<string | RegExp> | undefined;
propagateTraceHeaderCorsUrls?: (string | RegExp)[];
networkHeadersCapture?: boolean;
networkBodyCapture?: boolean;
}

class TaskCounter {
Expand Down Expand Up @@ -570,6 +573,7 @@ export function instrumentXHROriginal(config: XhrConfig) {
export function instrumentXHR(config: XhrConfig) {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;

const tracer = api.trace.getTracer('xhr');
const taskCounter = new TaskCounter();
Expand Down Expand Up @@ -662,31 +666,47 @@ export function instrumentXHR(config: XhrConfig) {
}
}

function _normalizeHeaders(
type: string,
headersString: string
): { [key: string]: string } {
function _normalizeHeader([key, value]: [string, string]): Record<
string,
api.AttributeValue
> {
const normalizedKey = key.toLowerCase().replace(/-/g, '_').trim();
let normalizedValue: api.AttributeValue;

// https://github.com/open-telemetry/opentelemetry-js/blob/82b7526b028a34a23936016768f37df05effcd59/experimental/packages/opentelemetry-instrumentation-http/src/utils.ts#L604C1-L611C1
if (typeof value === 'string') {
normalizedValue = [value];
} else if (Array.isArray(value)) {
normalizedValue = value;
} else {
normalizedValue = [value];
}
return { [normalizedKey]: normalizedValue };
}

function _normalizeHeaders(headersString: string): {
[key: string]: api.AttributeValue;
} {
const lines = headersString.trim().split('\n');
const normalizedHeaders: { [key: string]: string } = {};
const normalizedHeaders: { [key: string]: api.AttributeValue } = {};

lines.forEach((line) => {
let [key, value] = line.split(/:\s*/);
let [key, value] = line.trim().split(/:\s*/);
if (key && value) {
key = key.replace(/-/g, '_').toLowerCase();
const newKey = `http.${type}.header.${key}`;
normalizedHeaders[newKey] = value.trim();
Object.assign(normalizedHeaders, _normalizeHeader([key, value]));
}
});

return normalizedHeaders;
}

function _setHeaderAttributeForSpan(
normalizedHeader: { [key: string]: string },
normalizedHeaders: { [key: string]: api.AttributeValue },
type: 'request' | 'response',
span: api.Span
) {
Object.entries(normalizedHeader).forEach(([key, value]) => {
span.setAttribute(key, value as api.AttributeValue);
Object.entries(normalizedHeaders).forEach(([key, value]) => {
span.setAttribute(`http.${type}.header.${key}`, value);
});
}

Expand Down Expand Up @@ -722,12 +742,30 @@ export function instrumentXHR(config: XhrConfig) {

function _handleHeaderCapture(headers: string, currentSpan: api.Span) {
if (config.networkHeadersCapture) {
const normalizedHeaders = _normalizeHeaders('response', headers);
_setHeaderAttributeForSpan(normalizedHeaders, currentSpan);
const normalizedHeaders = _normalizeHeaders(headers);
_setHeaderAttributeForSpan(normalizedHeaders, 'response', currentSpan);
}
}

if (config.networkHeadersCapture) {
XMLHttpRequest.prototype.setRequestHeader = function (
this: XMLHttpRequest,
...args
) {
const [key, value] = args;
const xhrMem = _xhrMem.get(this);
if (xhrMem && key && value) {
const normalizedHeader = _normalizeHeader([key, value]);
// TODO: Store and dedupe the headers before adding them to the span
_setHeaderAttributeForSpan(normalizedHeader, 'request', xhrMem.span);
}
originalSetRequestHeader.apply(this, args);
};
}

XMLHttpRequest.prototype.send = function (this: XMLHttpRequest, ...args) {
const requestBody = args[0];

const xhrMem = _xhrMem.get(this);
if (!xhrMem) {
return originalSend.apply(this, args);
Expand All @@ -742,6 +780,22 @@ export function instrumentXHR(config: XhrConfig) {
taskCounter.increment();
xhrMem.sendStartTime = hrTime();
currentSpan.addEvent(EventNames.METHOD_SEND);
if (config.networkBodyCapture) {
let body: string = '';
if (typeof requestBody === 'string') {
body = requestBody;
} else {
try {
body = JSON.stringify(requestBody);
} catch (e) {
body = '[object of type ' + typeof requestBody + ']';
}
}
currentSpan.setAttribute(
'http.request.body',
body.slice(0, MAX_BODY_LENGTH)
);
}
this.addEventListener('readystatechange', () => {
if (this.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
const headers = this.getAllResponseHeaders().toLowerCase();
Expand All @@ -753,7 +807,24 @@ export function instrumentXHR(config: XhrConfig) {
}
}
} else if (this.readyState === XMLHttpRequest.DONE) {
endSpan(EventNames.EVENT_READY_STATE_CHANGE, this);
if (config.networkBodyCapture && this.responseType === 'blob') {
new Response(this.response)
.text()
.then((text) => {
currentSpan.setAttribute('http.response.body', text);
})
.finally(() => {
endSpan(EventNames.EVENT_READY_STATE_CHANGE, this);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just double check. the span would never end before this ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it shouldn't

});
} else {
if (config.networkBodyCapture) {
currentSpan.setAttribute(
'http.response.body',
this.responseText
);
}
endSpan(EventNames.EVENT_READY_STATE_CHANGE, this);
}
}
});
addHeaders(this, spanUrl);
Expand Down
2 changes: 2 additions & 0 deletions src/splunkRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface ReactNativeConfiguration {
beaconEndpoint?: string;
apiKey: string;
networkHeadersCapture?: boolean;
networkBodyCapture?: boolean;
service: string;
deploymentEnvironment?: string;
allowInsecureBeacon?: boolean;
Expand Down Expand Up @@ -171,6 +172,7 @@ export const HyperDXRum: HyperDXRumType = {
ignoreUrls: config.ignoreUrls,
propagateTraceHeaderCorsUrls: config.tracePropagationTargets,
networkHeadersCapture: config.networkHeadersCapture,
networkBodyCapture: config.networkBodyCapture,
});
instrumentErrors();

Expand Down
Loading