Skip to content

Commit

Permalink
feat: introduce internal http client
Browse files Browse the repository at this point in the history
  • Loading branch information
legendecas committed Jan 30, 2023
1 parent d1f9594 commit 1df055e
Show file tree
Hide file tree
Showing 32 changed files with 1,472 additions and 278 deletions.
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

0 comments on commit 1df055e

Please sign in to comment.