Skip to content

Commit ad3e0d3

Browse files
authored
SigV4 Authentication Support for OTLP HTTP Logs Exporter (#181)
*Issue #, if available:* Supporting ADOT JS auto instrumentation to automatically inject SigV4 authentication headers for outgoing log requests to the allow exporting to the AWS Logs OTLP endpoint. Users will need to configure the following environment variables in order to enable and properly run this exporter: `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://logs.[AWS-REGION].amazonaws.com/v1/logs`; **required** `OTEL_EXPORTER_OTLP_LOGS_HEADERS`=`x-aws-log-group=[CW-LOG-GROUP-NAME],x-aws-log-stream=[CW-LOG-STREAM-NAME]` **required** `OTEL_LOGS_EXPORTER=otlp` **required or do not set env variable** `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/protobuf` **required or do not set env variable** `OTEL_METRICS_EXPORTER=none` **This feature currently supports only 2 logging libraries by auto-instrumentation**, `Bunyan` and `Winston`: https://docs.honeycomb.io/send-data/logs/opentelemetry/sdk/javascript/ *Description of changes:* 1. Add new AwsAuthenticator class used by both OtlpAwsLogExporter and OtlpAwsSpanExporter which extends the upstream OTLPProtoLogExporter to inject Sigv4 headers directly into the headers. 2. Modified ADOT JS auto instrumentation to automatically detect if a user is exporting to CW Logs OTLP Logs endpoint by checking if the environment variable `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` is configured to match this url pattern: `https://logs.[AWS-REGION].amazonaws.com/v1/logs` **Testing:** 1. E2E test done in an empty EC2 environment without configuring .aws credentials config file or setting AWS credentials in the environment variable 2. Manual testing was done by configuring the above environment variables and setting up the sample app locally with ADOT auto instrumentation and verified the logs in CW Logs. 4. Unit tests were added to verify functionality of OtlpAwsLogsExporter By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. Example log in CW Logs: ``` { "resource": { "attributes": { "service.name": "unknown_service:/home/ec2-user/.local/share/mise/installs/node/22.14.0/bin/node", "process.command_args": [ "/home/ec2-user/.local/share/mise/installs/node/22.14.0/bin/node", "--experimental-network-inspection", "--require", "@aws/aws-distro-opentelemetry-node-autoinstrumentation/register", "/home/ec2-user/aws-otel-js-instrumentation/sample-applications/simple-express-server/sample-app-express-server.js" ], "process.runtime.version": "22.14.0", "process.pid": 1599620, "process.executable.name": "/home/ec2-user/.local/share/mise/installs/node/22.14.0/bin/node", "telemetry.sdk.name": "opentelemetry", "process.owner": "ec2-user", "telemetry.sdk.language": "nodejs", "process.runtime.name": "nodejs", "process.executable.path": "/home/ec2-user/.local/share/mise/installs/node/22.14.0/bin/node", "host.arch": "amd64", "telemetry.sdk.version": "1.30.1", "process.command": "/home/ec2-user/aws-otel-js-instrumentation/sample-applications/simple-express-server/sample-app-express-server.js", "host.name": "ip-172-31-7-29.us-west-2.compute.internal", "process.runtime.description": "Node.js", "telemetry.auto.version": "0.6.0-dev0-aws", "host.id": "ec2ccd3acc52be039f977d9b1de7c64d" } }, "scope": { "name": "default" }, "timeUnixNano": 1748220324612000000, "observedTimeUnixNano": 1748220324612000000, "severityNumber": 9, "severityText": "INFO", "body": "Received request to /rolldice", "attributes": { "endpoint": "/rolldice" }, "flags": 1, "traceId": "6833b9a4cccf4d9bfb82b11686fd8f63", "spanId": "bd19f2fcbc107755" } ```
1 parent 56b1b4f commit ad3e0d3

File tree

15 files changed

+1432
-454
lines changed

15 files changed

+1432
-454
lines changed

aws-distro-opentelemetry-node-autoinstrumentation/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
],
4242
"exclude": [
4343
"src/third-party/**/*.ts",
44-
"src/otlp-aws-span-exporter.ts"
44+
"src/exporter/otlp/aws/common/aws-authenticator.ts"
4545
]
4646
},
4747
"bugs": {
@@ -108,6 +108,9 @@
108108
"@opentelemetry/exporter-metrics-otlp-grpc": "0.57.1",
109109
"@opentelemetry/exporter-metrics-otlp-http": "0.57.1",
110110
"@opentelemetry/exporter-trace-otlp-proto": "0.57.1",
111+
"@opentelemetry/exporter-logs-otlp-grpc": "0.57.1",
112+
"@opentelemetry/exporter-logs-otlp-http": "0.57.1",
113+
"@opentelemetry/exporter-logs-otlp-proto": "0.57.1",
111114
"@opentelemetry/exporter-zipkin": "1.30.1",
112115
"@opentelemetry/id-generator-aws-xray": "1.2.3",
113116
"@opentelemetry/instrumentation": "0.57.1",
@@ -119,6 +122,7 @@
119122
"@opentelemetry/sdk-metrics": "1.30.1",
120123
"@opentelemetry/sdk-node": "0.57.1",
121124
"@opentelemetry/sdk-trace-base": "1.30.1",
125+
"@opentelemetry/sdk-logs": "0.57.1",
122126
"@opentelemetry/semantic-conventions": "1.28.0"
123127
},
124128
"files": [

aws-distro-opentelemetry-node-autoinstrumentation/src/aws-opentelemetry-configurator.ts

Lines changed: 229 additions & 10 deletions
Large diffs are not rendered by default.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { diag } from '@opentelemetry/api';
4+
import { getNodeVersion } from '../../../../utils';
5+
let SignatureV4: any;
6+
let HttpRequest: any;
7+
let defaultProvider: any;
8+
let Sha256: any;
9+
10+
let dependenciesLoaded = false;
11+
12+
if (getNodeVersion() >= 16) {
13+
try {
14+
defaultProvider = require('@aws-sdk/credential-provider-node').defaultProvider;
15+
Sha256 = require('@aws-crypto/sha256-js').Sha256;
16+
SignatureV4 = require('@smithy/signature-v4').SignatureV4;
17+
HttpRequest = require('@smithy/protocol-http').HttpRequest;
18+
dependenciesLoaded = true;
19+
} catch (error) {
20+
diag.error(`Failed to load required AWS dependency for SigV4 Signing: ${error}`);
21+
}
22+
} else {
23+
diag.error('SigV4 signing requires at least Node major version 16');
24+
}
25+
26+
// See: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
27+
export const AUTHORIZATION_HEADER = 'authorization';
28+
export const X_AMZ_DATE_HEADER = 'x-amz-date';
29+
export const X_AMZ_SECURITY_TOKEN_HEADER = 'x-amz-security-token';
30+
export const X_AMZ_CONTENT_SHA256_HEADER = 'x-amz-content-sha256';
31+
32+
export class AwsAuthenticator {
33+
private endpoint: URL;
34+
private region: string;
35+
private service: string;
36+
37+
constructor(endpoint: string, service: string) {
38+
// The endpoint is pre-validated by the config with isAwsOtlpEndpoint, so then endpoint is guaranteed to be well formatted and
39+
// new URL() will not throw
40+
this.endpoint = new URL(endpoint);
41+
this.region = endpoint.split('.')[1];
42+
this.service = service;
43+
}
44+
45+
public async authenticate(headers: Record<string, string>, serializedData: Uint8Array | undefined) {
46+
// Only do SigV4 Signing if the required dependencies are installed.
47+
if (dependenciesLoaded && serializedData) {
48+
const cleanedHeaders = this.removeSigV4Headers(headers);
49+
50+
const request = new HttpRequest({
51+
method: 'POST',
52+
protocol: 'https',
53+
hostname: this.endpoint.hostname,
54+
path: this.endpoint.pathname,
55+
body: serializedData,
56+
headers: {
57+
...cleanedHeaders,
58+
host: this.endpoint.hostname,
59+
},
60+
});
61+
62+
try {
63+
const signer = new SignatureV4({
64+
credentials: defaultProvider(),
65+
region: this.region,
66+
service: this.service,
67+
sha256: Sha256,
68+
});
69+
70+
const signedRequest = await signer.sign(request);
71+
72+
return signedRequest.headers;
73+
} catch (exception) {
74+
diag.debug(`Failed to sign/authenticate the given export request with error: ${exception}`);
75+
return undefined;
76+
}
77+
}
78+
79+
diag.debug('No serialized data provided. Not authenticating.');
80+
return undefined;
81+
}
82+
83+
// Cleans up Sigv4 from headers to avoid accidentally copying them to the new headers
84+
private removeSigV4Headers(headers: Record<string, string>) {
85+
const newHeaders: Record<string, string> = {};
86+
const sigv4Headers = [
87+
AUTHORIZATION_HEADER,
88+
X_AMZ_CONTENT_SHA256_HEADER,
89+
X_AMZ_DATE_HEADER,
90+
X_AMZ_CONTENT_SHA256_HEADER,
91+
];
92+
93+
for (const key in headers) {
94+
if (!sigv4Headers.includes(key.toLowerCase())) {
95+
newHeaders[key] = headers[key];
96+
}
97+
}
98+
return newHeaders;
99+
}
100+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { CompressionAlgorithm, OTLPExporterBase } from '@opentelemetry/otlp-exporter-base';
4+
import { gzipSync } from 'zlib';
5+
import { ExportResult, ExportResultCode } from '@opentelemetry/core';
6+
import { AwsAuthenticator } from './aws-authenticator';
7+
import { ISerializer } from '@opentelemetry/otlp-transformer';
8+
9+
/**
10+
* Base class for AWS OTLP exporters
11+
*/
12+
export abstract class OTLPAwsBaseExporter<Payload, Response> extends OTLPExporterBase<Payload> {
13+
protected parentExporter: OTLPExporterBase<Payload>;
14+
private readonly compression?: CompressionAlgorithm;
15+
private endpoint: string;
16+
private serializer: PassthroughSerializer<Response>;
17+
private authenticator: AwsAuthenticator;
18+
private parentSerializer: ISerializer<Payload, Response>;
19+
20+
constructor(
21+
endpoint: string,
22+
service: string,
23+
parentExporter: OTLPExporterBase<Payload>,
24+
parentSerializer: ISerializer<Payload, Response>,
25+
compression?: CompressionAlgorithm
26+
) {
27+
super(parentExporter['_delegate']);
28+
this.compression = compression;
29+
this.endpoint = endpoint;
30+
this.authenticator = new AwsAuthenticator(this.endpoint, service);
31+
this.parentExporter = parentExporter;
32+
this.parentSerializer = parentSerializer;
33+
34+
// To prevent performance degradation from serializing and compressing data twice, we handle serialization and compression
35+
// locally in this exporter and pass the pre-processed data to the upstream export.
36+
// This is used in order to prevent serializing and compressing the data again when calling parentExporter.export().
37+
// To see why this works:
38+
// https://github.com/open-telemetry/opentelemetry-js/blob/ec17ce48d0e5a99a122da5add612a20e2dd84ed5/experimental/packages/otlp-exporter-base/src/otlp-export-delegate.ts#L69
39+
this.serializer = new PassthroughSerializer<Response>(this.parentSerializer.deserializeResponse);
40+
this.parentExporter['_delegate']._serializer = this.serializer;
41+
}
42+
43+
/**
44+
* Overrides the upstream implementation of export.
45+
* All behaviors are the same except if the endpoint is an AWS OTLP endpoint, we will sign the request with SigV4
46+
* in headers before sending it to the endpoint.
47+
* @param items - Array of signal data to export
48+
* @param resultCallback - Callback function to handle export result
49+
*/
50+
override async export(items: Payload, resultCallback: (result: ExportResult) => void): Promise<void> {
51+
const headers = this.parentExporter['_delegate']._transport?._transport?._parameters?.headers();
52+
53+
if (!headers) {
54+
resultCallback({
55+
code: ExportResultCode.FAILED,
56+
error: new Error(`Request headers are unset - unable to export to ${this.endpoint}`),
57+
});
58+
return;
59+
}
60+
61+
let serializedData: Uint8Array | undefined = this.parentSerializer.serializeRequest(items);
62+
63+
if (!serializedData) {
64+
resultCallback({
65+
code: ExportResultCode.FAILED,
66+
error: new Error('Nothing to send'),
67+
});
68+
return;
69+
}
70+
71+
delete headers['Content-Encoding'];
72+
const shouldCompress = this.compression && this.compression !== CompressionAlgorithm.NONE;
73+
74+
if (shouldCompress) {
75+
try {
76+
serializedData = gzipSync(serializedData);
77+
headers['Content-Encoding'] = 'gzip';
78+
} catch (exception) {
79+
resultCallback({
80+
code: ExportResultCode.FAILED,
81+
error: new Error(`Failed to compress: ${exception}`),
82+
});
83+
return;
84+
}
85+
}
86+
87+
this.serializer.setSerializedData(serializedData);
88+
const signedHeaders = await this.authenticator.authenticate(headers, serializedData);
89+
90+
if (!signedHeaders) {
91+
resultCallback({
92+
code: ExportResultCode.FAILED,
93+
error: new Error('Sigv4 Signing Failed. Not exporting'),
94+
});
95+
return;
96+
}
97+
98+
this.parentExporter['_delegate']._transport._transport._parameters.headers = () => signedHeaders;
99+
this.parentExporter.export(items, resultCallback);
100+
}
101+
102+
override shutdown(): Promise<void> {
103+
return this.parentExporter.shutdown();
104+
}
105+
106+
override forceFlush(): Promise<void> {
107+
return this.parentExporter.forceFlush();
108+
}
109+
}
110+
111+
/**
112+
* A serializer that bypasses request serialization by returning pre-serialized data.
113+
* @template Response The type of the deserialized response
114+
*/
115+
class PassthroughSerializer<Response> implements ISerializer<Uint8Array, Response> {
116+
private serializedData: Uint8Array = new Uint8Array();
117+
private deserializer: (data: Uint8Array) => Response;
118+
119+
/**
120+
* Creates a new PassthroughSerializer instance.
121+
* @param deserializer Function to deserialize response data
122+
*/
123+
constructor(deserializer: (data: Uint8Array) => Response) {
124+
this.deserializer = deserializer;
125+
}
126+
127+
/**
128+
* Sets the pre-serialized data to be returned when serializeRequest is called.
129+
* @param data The serialized data to use
130+
*/
131+
setSerializedData(data: Uint8Array): void {
132+
this.serializedData = data;
133+
}
134+
135+
/**
136+
* Returns the pre-serialized data, ignoring the request parameter.
137+
* @param request Ignored parameter.
138+
* @returns The pre-serialized data
139+
*/
140+
serializeRequest(request: Uint8Array): Uint8Array {
141+
return this.serializedData;
142+
}
143+
144+
/**
145+
* Deserializes response data using the provided deserializer function.
146+
* @param data The response data to deserialize
147+
* @returns The deserialized response
148+
*/
149+
deserializeResponse(data: Uint8Array): Response {
150+
return this.deserializer(data);
151+
}
152+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { OTLPLogExporter as OTLPProtoLogExporter } from '@opentelemetry/exporter-logs-otlp-proto';
4+
import { CompressionAlgorithm, OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base';
5+
import { IExportLogsServiceResponse, ProtobufLogsSerializer } from '@opentelemetry/otlp-transformer';
6+
import { LogRecordExporter, ReadableLogRecord } from '@opentelemetry/sdk-logs';
7+
import { OTLPAwsBaseExporter } from '../common/otlp-aws-base-exporter';
8+
9+
/**
10+
* This exporter extends the functionality of the OTLPProtoLogExporter to allow logs to be exported
11+
* to the CloudWatch Logs OTLP endpoint https://logs.[AWSRegion].amazonaws.com/v1/logs. Utilizes the aws-sdk
12+
* library to sign and directly inject SigV4 Authentication to the exported request's headers. <a
13+
* href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">...</a>
14+
*
15+
* This only works with version >=16 Node.js environments.
16+
* @param endpoint - The AWS CloudWatch Logs OTLP endpoint URL
17+
* @param config - Optional OTLP exporter configuration
18+
*/
19+
export class OTLPAwsLogExporter
20+
extends OTLPAwsBaseExporter<ReadableLogRecord[], IExportLogsServiceResponse>
21+
implements LogRecordExporter
22+
{
23+
constructor(endpoint: string, config?: OTLPExporterNodeConfigBase) {
24+
const modifiedConfig: OTLPExporterNodeConfigBase = {
25+
...config,
26+
url: endpoint,
27+
compression: CompressionAlgorithm.NONE,
28+
};
29+
30+
super(endpoint, 'logs', new OTLPProtoLogExporter(modifiedConfig), ProtobufLogsSerializer, config?.compression);
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { OTLPTraceExporter as OTLPProtoTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
4+
import { CompressionAlgorithm, OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base';
5+
import { IExportTraceServiceResponse, ProtobufTraceSerializer } from '@opentelemetry/otlp-transformer';
6+
import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
7+
import { OTLPAwsBaseExporter } from '../common/otlp-aws-base-exporter';
8+
import { LLOHandler } from '../../../../llo-handler';
9+
import { LoggerProvider as APILoggerProvider, logs } from '@opentelemetry/api-logs';
10+
import { ExportResult } from '@opentelemetry/core';
11+
import { isAgentObservabilityEnabled } from '../../../../utils';
12+
import { diag } from '@opentelemetry/api';
13+
import { LoggerProvider } from '@opentelemetry/sdk-logs';
14+
15+
/**
16+
* This exporter extends the functionality of the OTLPProtoTraceExporter to allow spans to be exported
17+
* to the XRay OTLP endpoint https://xray.[AWSRegion].amazonaws.com/v1/traces. Utilizes the aws-sdk
18+
* library to sign and directly inject SigV4 Authentication to the exported request's headers. <a
19+
* href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html">...</a>
20+
*
21+
* This only works with version >=16 Node.js environments.
22+
*
23+
* @param endpoint - The AWS X-Ray OTLP endpoint URL
24+
* @param config - Optional OTLP exporter configuration
25+
*/
26+
export class OTLPAwsSpanExporter
27+
extends OTLPAwsBaseExporter<ReadableSpan[], IExportTraceServiceResponse>
28+
implements SpanExporter
29+
{
30+
private loggerProvider: APILoggerProvider | undefined;
31+
private lloHandler: LLOHandler | undefined;
32+
33+
constructor(endpoint: string, config?: OTLPExporterNodeConfigBase, loggerProvider?: APILoggerProvider) {
34+
const modifiedConfig: OTLPExporterNodeConfigBase = {
35+
...config,
36+
url: endpoint,
37+
compression: CompressionAlgorithm.NONE,
38+
};
39+
40+
super(endpoint, 'xray', new OTLPProtoTraceExporter(modifiedConfig), ProtobufTraceSerializer, config?.compression);
41+
42+
this.lloHandler = undefined;
43+
this.loggerProvider = loggerProvider;
44+
}
45+
46+
// Lazily initialize LLO handler when needed to avoid initialization order issues
47+
private ensureLloHandler(): boolean {
48+
if (!this.lloHandler && isAgentObservabilityEnabled()) {
49+
// If loggerProvider wasn't provided, try to get the current one
50+
if (!this.loggerProvider) {
51+
try {
52+
this.loggerProvider = logs.getLoggerProvider();
53+
} catch (e: unknown) {
54+
diag.debug('Failed to get logger provider', e);
55+
return false;
56+
}
57+
}
58+
59+
if (this.loggerProvider instanceof LoggerProvider) {
60+
this.lloHandler = new LLOHandler(this.loggerProvider);
61+
return true;
62+
}
63+
}
64+
65+
return !!this.lloHandler;
66+
}
67+
68+
override async export(items: ReadableSpan[], resultCallback: (result: ExportResult) => void): Promise<void> {
69+
let itemsToSerialize: ReadableSpan[] = items;
70+
if (isAgentObservabilityEnabled() && this.ensureLloHandler() && this.lloHandler) {
71+
// items to serialize are now the lloProcessedSpans
72+
itemsToSerialize = this.lloHandler.processSpans(items);
73+
}
74+
75+
return super.export(itemsToSerialize, resultCallback);
76+
}
77+
}

0 commit comments

Comments
 (0)