diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 1ac87111b5..4ce74a2709 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to experimental packages in this project will be documented ### :bug: (Bug Fix) +* fix(exporter-logs-otlp-http): set useHex to true [#3875](https://github.com/open-telemetry/opentelemetry-js/pull/3875) @Nico385412 + ### :books: (Refine Doc) ### :house: (Internal) diff --git a/experimental/packages/exporter-logs-otlp-http/package.json b/experimental/packages/exporter-logs-otlp-http/package.json index 19ec49940b..41b72085be 100644 --- a/experimental/packages/exporter-logs-otlp-http/package.json +++ b/experimental/packages/exporter-logs-otlp-http/package.json @@ -73,6 +73,7 @@ "devDependencies": { "@babel/core": "7.22.5", "@opentelemetry/api-logs": "0.40.0", + "@opentelemetry/resources": "1.14.0", "@types/mocha": "10.0.1", "@types/node": "18.6.5", "@types/sinon": "10.0.15", diff --git a/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts b/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts index ef837614e2..a7ecbbac95 100644 --- a/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts +++ b/experimental/packages/exporter-logs-otlp-http/src/platform/browser/OTLPLogExporter.ts @@ -48,7 +48,7 @@ export class OTLPLogExporter } convert(logRecords: ReadableLogRecord[]): IExportLogsServiceRequest { - return createExportLogsServiceRequest(logRecords); + return createExportLogsServiceRequest(logRecords, true); } getDefaultUrl(config: OTLPExporterConfigBase): string { diff --git a/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts b/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts index 7f7c538df7..a1d101e87c 100644 --- a/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts +++ b/experimental/packages/exporter-logs-otlp-http/src/platform/node/OTLPLogExporter.ts @@ -48,7 +48,7 @@ export class OTLPLogExporter } convert(logRecords: ReadableLogRecord[]): IExportLogsServiceRequest { - return createExportLogsServiceRequest(logRecords); + return createExportLogsServiceRequest(logRecords, true); } getDefaultUrl(config: OTLPExporterNodeConfigBase): string { diff --git a/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts index 66958a9fef..2443c97ef4 100644 --- a/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts @@ -13,15 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import * as assert from 'assert'; import * as sinon from 'sinon'; import * as Config from '../../src/platform/config'; import { OTLPLogExporter } from '../../src/platform/browser'; +import { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; +import { ReadableLogRecord } from '@opentelemetry/sdk-logs'; +import { mockedReadableLogRecord } from '../logHelper'; +import { ExportResultCode } from '@opentelemetry/core'; describe('OTLPLogExporter', () => { let envSource: Record; + let collectorExporter: OTLPLogExporter; + let collectorExporterConfig: OTLPExporterConfigBase; + + afterEach(() => { + sinon.restore(); + }); if (typeof process === 'undefined') { envSource = globalThis as unknown as Record; @@ -61,4 +70,79 @@ describe('OTLPLogExporter', () => { assert.strictEqual(getDefaultUrl.callCount, 2); }); }); + + describe('export - common', () => { + let spySend: any; + beforeEach(() => { + spySend = sinon.stub(OTLPLogExporter.prototype, 'send'); + collectorExporter = new OTLPLogExporter(collectorExporterConfig); + }); + + it('should export spans as otlpTypes.Spans', done => { + const logs: ReadableLogRecord[] = []; + logs.push(Object.assign({}, mockedReadableLogRecord)); + + collectorExporter.export(logs, () => {}); + setTimeout(() => { + const log = spySend.args[0][0][0] as ReadableLogRecord; + assert.deepStrictEqual(logs[0], log); + done(); + }); + assert.strictEqual(spySend.callCount, 1); + }); + + describe('when exporter is shutdown', () => { + it( + 'should not export anything but return callback with code' + + ' "FailedNotRetryable"', + async () => { + const spans: ReadableLogRecord[] = []; + spans.push(Object.assign({}, mockedReadableLogRecord)); + await collectorExporter.shutdown(); + spySend.resetHistory(); + + const callbackSpy = sinon.spy(); + collectorExporter.export(spans, callbackSpy); + const returnCode = callbackSpy.args[0][0]; + + assert.strictEqual( + returnCode.code, + ExportResultCode.FAILED, + 'return value is wrong' + ); + assert.strictEqual(spySend.callCount, 0, 'should not call send'); + } + ); + }); + describe('when an error occurs', () => { + it('should return failed export result', done => { + const spans: ReadableLogRecord[] = []; + spans.push(Object.assign({}, mockedReadableLogRecord)); + spySend.throws({ + code: 100, + details: 'Test error', + metadata: {}, + message: 'Non-retryable', + stack: 'Stack', + }); + const callbackSpy = sinon.spy(); + collectorExporter.export(spans, callbackSpy); + setTimeout(() => { + const returnCode = callbackSpy.args[0][0]; + assert.strictEqual( + returnCode.code, + ExportResultCode.FAILED, + 'return value is wrong' + ); + assert.strictEqual( + returnCode.error.message, + 'Non-retryable', + 'return error message is wrong' + ); + assert.strictEqual(spySend.callCount, 1, 'should call send'); + done(); + }); + }); + }); + }); }); diff --git a/experimental/packages/exporter-logs-otlp-http/test/logHelper.ts b/experimental/packages/exporter-logs-otlp-http/test/logHelper.ts new file mode 100644 index 0000000000..1d9461f873 --- /dev/null +++ b/experimental/packages/exporter-logs-otlp-http/test/logHelper.ts @@ -0,0 +1,166 @@ +/* + * 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 { HrTime, TraceFlags } from '@opentelemetry/api'; +import { SeverityNumber } from '@opentelemetry/api-logs'; +import { Resource } from '@opentelemetry/resources'; +import * as assert from 'assert'; +import { VERSION } from '@opentelemetry/core'; +import { + IAnyValue, + IExportLogsServiceRequest, + IKeyValue, + ILogRecord, + IResource, +} from '@opentelemetry/otlp-transformer'; +import { ReadableLogRecord } from '@opentelemetry/sdk-logs'; + +export const mockedReadableLogRecord: ReadableLogRecord = { + resource: Resource.default().merge( + new Resource({ + 'resource-attribute': 'some resource-attr value', + }) + ), + instrumentationScope: { + name: 'scope_name_1', + version: '0.1.0', + schemaUrl: 'http://url.to.schema', + }, + hrTime: [1680253513, 123241635] as HrTime, + hrTimeObserved: [1680253513, 123241635] as HrTime, + attributes: { + 'some-attribute': 'some attribute value', + }, + severityNumber: SeverityNumber.ERROR, + severityText: 'error', + body: 'some_log_body', + spanContext: { + traceFlags: TraceFlags.SAMPLED, + traceId: '1f1008dc8e270e85c40a0d7c3939b278', + spanId: '5e107261f64fa53e', + }, +}; +export function ensureExportedAttributesAreCorrect(attributes: IKeyValue[]) { + assert.deepStrictEqual( + attributes, + [ + { + key: 'some-attribute', + value: { + stringValue: 'some attribute value', + }, + }, + ], + 'exported attributes are incorrect' + ); +} + +export function ensureExportedBodyIsCorrect(body?: IAnyValue) { + assert.deepStrictEqual( + body, + { stringValue: 'some_log_body' }, + 'exported attributes are incorrect' + ); +} + +export function ensureExportedLogRecordIsCorrect(logRecord: ILogRecord) { + ensureExportedBodyIsCorrect(logRecord.body); + ensureExportedAttributesAreCorrect(logRecord.attributes); + assert.strictEqual( + logRecord.timeUnixNano, + 1680253513123241700, + 'timeUnixNano is wrong' + ); + assert.strictEqual( + logRecord.observedTimeUnixNano, + 1680253513123241700, + 'observedTimeUnixNano is wrong' + ); + assert.strictEqual( + logRecord.severityNumber, + SeverityNumber.ERROR, + 'severityNumber is wrong' + ); + assert.strictEqual(logRecord.severityText, 'error', 'severityText is wrong'); + assert.strictEqual( + logRecord.droppedAttributesCount, + 0, + 'droppedAttributesCount is wrong' + ); + assert.strictEqual(logRecord.flags, TraceFlags.SAMPLED, 'flags is wrong'); +} + +export function ensureResourceIsCorrect(resource: IResource) { + assert.deepStrictEqual(resource, { + attributes: [ + { + key: 'service.name', + value: { + stringValue: `unknown_service:${process.argv0}`, + value: 'stringValue', + }, + }, + { + key: 'telemetry.sdk.language', + value: { + stringValue: 'nodejs', + value: 'stringValue', + }, + }, + { + key: 'telemetry.sdk.name', + value: { + stringValue: 'opentelemetry', + value: 'stringValue', + }, + }, + { + key: 'telemetry.sdk.version', + value: { + stringValue: VERSION, + value: 'stringValue', + }, + }, + { + key: 'resource-attribute', + value: { + stringValue: 'some resource-attr value', + value: 'stringValue', + }, + }, + ], + droppedAttributesCount: 0, + }); +} + +export function ensureExportLogsServiceRequestIsSet( + json: IExportLogsServiceRequest +) { + const resourceLogs = json.resourceLogs; + assert.strictEqual(resourceLogs?.length, 1, 'resourceLogs is missing'); + + const resource = resourceLogs?.[0].resource; + assert.ok(resource, 'resource is missing'); + + const scopeLogs = resourceLogs?.[0].scopeLogs; + assert.strictEqual(scopeLogs?.length, 1, 'scopeLogs is missing'); + + const scope = scopeLogs?.[0].scope; + assert.ok(scope, 'scope is missing'); + + const logRecords = scopeLogs?.[0].logRecords; + assert.strictEqual(logRecords?.length, 1, 'logs are missing'); +} diff --git a/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts index aa0d345348..2ae11142ad 100644 --- a/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-http/test/node/OTLPLogExporter.test.ts @@ -14,14 +14,55 @@ * limitations under the License. */ +import { diag } from '@opentelemetry/api'; import * as assert from 'assert'; +import * as http from 'http'; import * as sinon from 'sinon'; - import * as Config from '../../src/platform/config'; + import { OTLPLogExporter } from '../../src/platform/node'; +import { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'; +import { ReadableLogRecord } from '@opentelemetry/sdk-logs'; +import { + ensureExportLogsServiceRequestIsSet, + ensureExportedLogRecordIsCorrect, + mockedReadableLogRecord, +} from '../logHelper'; +import { PassThrough, Stream } from 'stream'; +import { IExportLogsServiceRequest } from '@opentelemetry/otlp-transformer'; +import { ExportResultCode } from '@opentelemetry/core'; + +let fakeRequest: PassThrough; + +class MockedResponse extends Stream { + constructor(private _code: number, private _msg?: string) { + super(); + } + + send(data: string) { + this.emit('data', data); + this.emit('end'); + } + + get statusCode() { + return this._code; + } + + get statusMessage() { + return this._msg; + } +} describe('OTLPLogExporter', () => { let envSource: Record; + let collectorExporter: OTLPLogExporter; + let collectorExporterConfig: OTLPExporterNodeConfigBase; + let logs: ReadableLogRecord[]; + + afterEach(() => { + fakeRequest = new Stream.PassThrough(); + sinon.restore(); + }); if (typeof process === 'undefined') { envSource = globalThis as unknown as Record; @@ -61,4 +102,130 @@ describe('OTLPLogExporter', () => { assert.strictEqual(getDefaultUrl.callCount, 2); }); }); + + describe('export', () => { + beforeEach(() => { + collectorExporterConfig = { + headers: { + foo: 'bar', + }, + hostname: 'foo', + url: 'http://foo.bar.com', + keepAlive: true, + httpAgentOptions: { keepAliveMsecs: 2000 }, + }; + collectorExporter = new OTLPLogExporter(collectorExporterConfig); + logs = []; + logs.push(Object.assign({}, mockedReadableLogRecord)); + }); + afterEach(() => { + sinon.restore(); + }); + + it('should open the connection', done => { + sinon.stub(http, 'request').callsFake((options: any, cb: any) => { + assert.strictEqual(options.hostname, 'foo.bar.com'); + assert.strictEqual(options.method, 'POST'); + assert.strictEqual(options.path, '/'); + + const mockRes = new MockedResponse(200); + cb(mockRes); + mockRes.send('success'); + done(); + return fakeRequest as any; + }); + collectorExporter.export(logs, () => {}); + }); + + it('should set custom headers', done => { + sinon.stub(http, 'request').callsFake((options: any, cb: any) => { + assert.strictEqual(options.headers['foo'], 'bar'); + + const mockRes = new MockedResponse(200); + cb(mockRes); + mockRes.send('success'); + done(); + return fakeRequest as any; + }); + + collectorExporter.export(logs, () => {}); + }); + + it('should have keep alive and keepAliveMsecs option set', done => { + sinon.stub(http, 'request').callsFake((options: any, cb: any) => { + assert.strictEqual(options.agent.keepAlive, true); + assert.strictEqual(options.agent.options.keepAliveMsecs, 2000); + + const mockRes = new MockedResponse(200); + cb(mockRes); + mockRes.send('success'); + done(); + return fakeRequest as any; + }); + + collectorExporter.export(logs, () => {}); + }); + + it('should successfully send the logs', done => { + const fakeRequest = new Stream.PassThrough(); + sinon.stub(http, 'request').returns(fakeRequest as any); + + let buff = Buffer.from(''); + fakeRequest.on('end', () => { + const responseBody = buff.toString(); + const json = JSON.parse(responseBody) as IExportLogsServiceRequest; + const log1 = json.resourceLogs?.[0].scopeLogs?.[0].logRecords?.[0]; + assert.ok(typeof log1 !== 'undefined', "log doesn't exist"); + ensureExportedLogRecordIsCorrect(log1); + + ensureExportLogsServiceRequestIsSet(json); + + done(); + }); + + fakeRequest.on('data', chunk => { + buff = Buffer.concat([buff, chunk]); + }); + + const clock = sinon.useFakeTimers(); + collectorExporter.export(logs, () => {}); + clock.tick(200); + clock.restore(); + }); + + it('should log the successful message', done => { + // Need to stub/spy on the underlying logger as the "diag" instance is global + const spyLoggerError = sinon.stub(diag, 'error'); + + sinon.stub(http, 'request').callsFake((options: any, cb: any) => { + const mockRes = new MockedResponse(200); + cb(mockRes); + mockRes.send('success'); + return fakeRequest as any; + }); + + collectorExporter.export(logs, result => { + assert.strictEqual(result.code, ExportResultCode.SUCCESS); + assert.strictEqual(spyLoggerError.args.length, 0); + done(); + }); + }); + + it('should log the error message', done => { + sinon.stub(http, 'request').callsFake((options: any, cb: any) => { + const mockResError = new MockedResponse(400); + cb(mockResError); + mockResError.send('failed'); + + return fakeRequest as any; + }); + + collectorExporter.export(logs, result => { + assert.strictEqual(result.code, ExportResultCode.FAILED); + // @ts-expect-error verify error code + assert.strictEqual(result.error.code, 400); + done(); + }); + }); + }); }); diff --git a/experimental/packages/exporter-logs-otlp-http/tsconfig.json b/experimental/packages/exporter-logs-otlp-http/tsconfig.json index c4eda837a9..52330c182e 100644 --- a/experimental/packages/exporter-logs-otlp-http/tsconfig.json +++ b/experimental/packages/exporter-logs-otlp-http/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "esModuleInterop": true, "outDir": "build", "rootDir": "." }, @@ -24,6 +23,9 @@ }, { "path": "../sdk-logs" - } + }, + { + "path": "../../../packages/opentelemetry-resources" + }, ] }