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: introduce internal exporter http client #3577

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion packages/opentelemetry-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"prepublishOnly": "npm run compile",
"compile": "tsc --build tsconfig.json tsconfig.esm.json tsconfig.esnext.json",
"clean": "tsc --build --clean tsconfig.json tsconfig.esm.json tsconfig.esnext.json",
"test": "nyc ts-mocha -p tsconfig.json test/**/*.test.ts --exclude 'test/platform/browser/**/*.ts'",
"test": "nyc ts-mocha -p tsconfig.json 'test/**/*.test.ts' --exclude 'test/platform/browser/**/*.ts'",
"test:browser": "nyc karma start --single-run",
"tdd": "npm run tdd:node",
"tdd:node": "npm run test -- --watch-extensions ts --watch",
Expand Down
15 changes: 10 additions & 5 deletions packages/opentelemetry-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ export * from './common/types';
export * from './ExportResult';
export * from './version';
export * as baggageUtils from './baggage/utils';
export * from './platform';
export {
SDK_INFO,
_globalThis,
getEnv,
hexToBase64,
otperformance,
unrefTimer,
RandomIdGenerator,
} from './platform';
export * from './propagation/composite';
export * from './trace/W3CTraceContextPropagator';
export * from './trace/IdGenerator';
Expand All @@ -42,7 +50,4 @@ export * from './utils/url';
export * from './utils/wrap';
export * from './utils/callback';
export * from './version';
import { _export } from './internal/exporter';
export const internal = {
_export,
};
export * as internal from './internal';
105 changes: 105 additions & 0 deletions packages/opentelemetry-core/src/internal/http-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* 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.
*/

export interface RequestInit {
/**
* Defaults to "POST". Some http clients may not support methods orther than "POST", like 'sendBeacon'.
*/
method?: string;
/**
* Content type of the payload. Would be appended to request headers if the http client supports it.
*/
contentType?: string;
/**
* Request headers.
*/
headers?: Record<string, string>;
/**
* Suggestive option, this may not be supported by every http client. After the specified milliseconds the request should timeout.
*/
timeoutMs?: number;
}

export class RetriableError extends Error {
override name = 'RetriableError';
/**
* @param retryAfterMillis a non-negative number indicating the milliseconds to delay after the response is received.
* If it is -1, it indicates delaying with exponential backoff.
* @param cause the original error.
*/
constructor(public retryAfterMillis: number, public cause?: Error) {
super(cause?.message);
}
}

export class HttpClientError extends Error {
override name = 'HttpClientError';
constructor(
public statusMessage?: string,
public statusCode?: number,
public payload?: unknown
) {
super(statusMessage);
}
}

export function isRetriableError(error: unknown): error is RetriableError {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (
error != null &&
typeof (error as RetriableError).retryAfterMillis === 'number'
);
}

const RETRIABLE_STATUS_CODES = [429, 502, 503, 504];
export function isRetriableStatusCode(status?: number): boolean {
return status != null && RETRIABLE_STATUS_CODES.includes(status);
}

export function parseRetryAfterToMills(retryAfter?: string | null): number {
if (retryAfter == null) {
return -1;
}
const seconds = Number.parseInt(retryAfter, 10);
if (Number.isInteger(seconds)) {
return seconds > 0 ? seconds * 1000 : -1;
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#directives
const delay = new Date(retryAfter).getTime() - Date.now();
if (delay >= 0) {
return delay * 1000;
}
return 0;
}

export type BufferLike = Uint8Array | string;

export type HttpClient =
| 'XMLHttpReuqest'
| 'fetch'
| 'node:http'
| 'sendBeacon';

/**
* Maximum compatible http request function that can be built on top of various http client ({@link HttpClient}).
* The returned promise can be rejected by {@link RetriableError} and the initiator should try to retry the request
* after backoffMs.
*/
export type RequestFunction = (
url: string,
payload: BufferLike,
requestInit?: RequestInit
) => Promise<void>;
62 changes: 62 additions & 0 deletions packages/opentelemetry-core/src/internal/http-clients/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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.
*/

import {
HttpClientError,
isRetriableStatusCode,
parseRetryAfterToMills,
RequestFunction,
RetriableError,
} from '../http-client';

export const isFetchRequestAvailable = () => {
return typeof fetch === 'function';
};
export const fetchRequest: RequestFunction = (url, payload, requestInit) => {
const abortController = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
if (requestInit?.timeoutMs) {
timeout = setTimeout(() => {
abortController.abort();
}, requestInit?.timeoutMs);
}

return fetch(url, {
body: payload,
method: requestInit?.method,
headers: requestInit?.headers,
signal: abortController.signal,
}).then(
response => {
clearTimeout(timeout);
if (response.status >= 200 && response.status <= 299) {
return;
}
const error = new HttpClientError(response.statusText, response.status);
if (isRetriableStatusCode(response.status)) {
throw new RetriableError(
parseRetryAfterToMills(response.headers.get('Retry-After')),
error
);
}
throw error;
},
error => {
clearTimeout(timeout);
throw error;
}
);
};
142 changes: 142 additions & 0 deletions packages/opentelemetry-core/src/internal/http-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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.
*/

import { diag } from '@opentelemetry/api';
import { ExportResult, ExportResultCode } from '../ExportResult';
import {
BufferLike,
HttpClient,
isRetriableError,
RequestFunction,
RequestInit,
} from './http-client';
import { fetchRequest, isFetchRequestAvailable } from './http-clients/fetch';
import { internal } from '../platform';

export interface ExportOptions extends RequestInit {
/**
* Defaults to 0.
*/
maxAttempts?: number;
initialBackoff?: number;
maxBackoff?: number;
backoffMultiplier?: number;
}

const DEFAULT_EXPORT_MAX_ATTEMPTS = 5;
const DEFAULT_EXPORT_INITIAL_BACKOFF = 1000;
const DEFAULT_EXPORT_MAX_BACKOFF = 5000;
const DEFAULT_EXPORT_BACKOFF_MULTIPLIER = 1.5;

const clients = {
fetch: [isFetchRequestAvailable, fetchRequest],
'node:http': [internal.isHttpRequestAvailable, internal.httpRequest],
XMLHttpReuqest: [internal.isXhrRequestAvailable, internal.xhrRequest],
sendBeacon: [
internal.isSendBeaconRequestAvailable,
internal.sendBeaconRequest,
],
} as const;
export function determineClient(
preferredClients: HttpClient[]
): RequestFunction {
for (let idx = 0; idx < preferredClients.length; idx++) {
const name = preferredClients[idx];
if (!(name in clients)) {
diag.error(`Http client "${name}" is not recognizable.`);
continue;
}
const [test, request] = clients[name];
if (!test()) {
diag.error(`Http client "${name}" is not available.`);
continue;
}
return request;
}
throw new Error(`No http client available: ${preferredClients}`);
}

export type HttpExportClient = (
url: string,
payload: BufferLike,
options?: ExportOptions
) => Promise<ExportResult>;

/**
* Creates a http-client based on the preference of the underlying methods.
* The abstract http-client only exposes necessary information to the exporters
* in order to broaden the compatibility across platforms.
*/
export function createHttpExportClient(
preferredClients: HttpClient[]
): HttpExportClient {
const request = determineClient(preferredClients);

/**
* Wraps common retrying process with http clients.
*/
return function httpExport(url, payload, options) {
let attemptCount = 0;
const maxAttempt = options?.maxAttempts ?? DEFAULT_EXPORT_MAX_ATTEMPTS;
const maxBackoff = options?.maxBackoff ?? DEFAULT_EXPORT_MAX_BACKOFF;
const initialBackoff =
options?.initialBackoff ?? DEFAULT_EXPORT_INITIAL_BACKOFF;
const backoffMultiplier =
options?.backoffMultiplier ?? DEFAULT_EXPORT_BACKOFF_MULTIPLIER;

let lastBackoff = initialBackoff;

const okHandler = (): ExportResult => {
return {
code: ExportResultCode.SUCCESS,
};
};
const errorHandler = (
err: unknown
): Promise<ExportResult> | ExportResult => {
if (!isRetriableError(err) || attemptCount >= maxAttempt) {
return {
code: ExportResultCode.FAILED,
error: err as Error,
};
}

let delay: number;
if (err.retryAfterMillis >= 0) {
delay = err.retryAfterMillis;
} else {
delay =
Math.round(Math.random() * (maxBackoff - lastBackoff) + lastBackoff) *
backoffMultiplier;
}

lastBackoff = delay;
attemptCount++;
return setTimeoutPromise(err.retryAfterMillis)
.then(() => request(url, payload, options))
.then(okHandler, errorHandler);
};
const p = request(url, payload, options).then(okHandler, errorHandler);

return p;
};
}

function setTimeoutPromise(timeoutMs: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, timeoutMs);
});
}
30 changes: 30 additions & 0 deletions packages/opentelemetry-core/src/internal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.
*/

import { _export } from './exporter';
import { HttpClient } from './http-client';
import {
createHttpExportClient,
HttpExportClient,
ExportOptions,
} from './http-export';
export {
_export,
createHttpExportClient,
HttpClient,
HttpExportClient,
ExportOptions,
};
3 changes: 3 additions & 0 deletions packages/opentelemetry-core/src/platform/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ export * from './RandomIdGenerator';
export * from './performance';
export * from './sdk-info';
export * from './timer-util';

import * as internal from './internal';
export { internal };
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@
* limitations under the License.
*/

export * from './util';
export { httpRequest, isHttpRequestAvailable } from './node-http';
export { isSendBeaconRequestAvailable, sendBeaconRequest } from './send-beacon';
export { isXhrRequestAvailable, xhrRequest } from './xhr';
Loading