Skip to content

Commit 4021154

Browse files
committed
feat: adding OpenTelemetry option
1 parent 92362fb commit 4021154

28 files changed

+2916
-156
lines changed

__tests__/core/runner.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
*/
2525

2626
import fs from 'fs';
27+
import { trace } from '@opentelemetry/api';
2728
import { Gatherer } from '../../src/core/gatherer';
2829
import Runner from '../../src/core/runner';
2930
import { step, journey, before, after } from '../../src/core';
@@ -42,6 +43,16 @@ import {
4243
RunOptions,
4344
StartEvent,
4445
} from '../../src/common_types';
46+
import { initOtel } from '../../src/otel/manager';
47+
import {
48+
getJourneySpanOptions,
49+
getStepSpanOptions,
50+
endJourneySpan,
51+
endStepSpan,
52+
} from '../../src/otel/events';
53+
54+
jest.mock('../../src/otel/events');
55+
jest.mock('../../src/otel/manager');
4556

4657
describe('runner', () => {
4758
let runner: Runner,
@@ -1014,4 +1025,55 @@ describe('runner', () => {
10141025
});
10151026
});
10161027
});
1028+
1029+
describe('OpenTelemetry', () => {
1030+
it('skips init instrumentation when not enabled', async () => {
1031+
await runner._run({ ...defaultRunOptions, otel: false });
1032+
expect(initOtel).not.toHaveBeenCalled();
1033+
});
1034+
1035+
it('inits and shutdowns instrumentation when enabled', async () => {
1036+
const shutdown = jest.fn();
1037+
(initOtel as jest.Mock).mockImplementation(() => ({
1038+
shutdown,
1039+
}));
1040+
await runner._run({ ...defaultRunOptions, otel: true });
1041+
expect(initOtel).toHaveBeenCalled();
1042+
expect(shutdown).toHaveBeenCalled();
1043+
});
1044+
1045+
it('records spans for journey and steps', async () => {
1046+
jest.spyOn(trace, 'getTracer').mockReturnValue({
1047+
startActiveSpan: (
1048+
name: string,
1049+
options: any,
1050+
fn: (span: any) => Promise<void>
1051+
) => {
1052+
return fn('span');
1053+
},
1054+
} as any);
1055+
1056+
const j1 = new Journey({ name: 'otel journey' }, noop);
1057+
const s1 = j1._addStep('step1', noop);
1058+
runner._addJourney(j1);
1059+
1060+
await runner._run({ ...defaultRunOptions, otel: true });
1061+
1062+
expect(trace.getTracer).toBeCalled();
1063+
expect(getJourneySpanOptions).toBeCalledWith(j1, expect.any(Date));
1064+
expect(endJourneySpan).toBeCalledWith({
1065+
span: 'span',
1066+
journey: j1,
1067+
endTime: expect.any(Number),
1068+
});
1069+
1070+
expect(getStepSpanOptions).toBeCalledWith(s1, expect.any(Date));
1071+
expect(endStepSpan).toBeCalledWith({
1072+
span: 'span',
1073+
step: s1,
1074+
data: {},
1075+
endTime: expect.any(Number),
1076+
});
1077+
});
1078+
});
10171079
});

__tests__/dsl/journey.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
*
2424
*/
2525

26+
import { trace } from '@opentelemetry/api';
2627
import { Journey, Step } from '../../src/dsl';
28+
import { addMonitorConfigAttributesToSpan } from '../../src/otel';
29+
30+
jest.mock('../../src/otel');
31+
jest.spyOn(trace, 'getActiveSpan').mockImplementation(() => 'span' as any);
2732

2833
const noop = () => {};
2934
describe('Journey', () => {
@@ -44,4 +49,16 @@ describe('Journey', () => {
4449
expect(s2).toBeInstanceOf(Step);
4550
expect(journey.steps.length).toBe(2);
4651
});
52+
53+
describe('OpenTelemetry', () => {
54+
it('updates span when otel enabled and monitor config is added', () => {
55+
const journey = new Journey({ name: 'j1' }, noop);
56+
journey._updateMonitor('config' as any);
57+
expect(trace.getActiveSpan).toHaveBeenCalled();
58+
expect(addMonitorConfigAttributesToSpan).toHaveBeenCalledWith(
59+
'span',
60+
'config'
61+
);
62+
});
63+
});
4764
});

__tests__/helper.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
microSecsToSeconds,
3535
wrapFnWithLocation,
3636
isMatch,
37+
maskCredentialsInURL,
3738
} from '../src/helpers';
3839

3940
it('indent message with seperator', () => {
@@ -146,3 +147,12 @@ it('match tags and names', () => {
146147
expect(isMatch(['bar'], 'foo', undefined, 'ba*')).toBe(true);
147148
expect(isMatch(['bar'], 'foo', undefined, 'test*')).toBe(false);
148149
});
150+
151+
it('mask credentials in URL', () => {
152+
expect(maskCredentialsInURL('https://user:password@example.com')).toBe(
153+
'https://***:***@example.com/'
154+
);
155+
expect(maskCredentialsInURL('https://example.com')).toBe(
156+
'https://example.com/'
157+
);
158+
});

__tests__/options.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('options', () => {
4444
sandbox: false,
4545
screenshots: 'on',
4646
dryRun: true,
47+
otel: true,
4748
match: 'check*',
4849
pauseOnError: true,
4950
config: join(__dirname, 'fixtures', 'synthetics.config.ts'),
@@ -52,9 +53,11 @@ describe('options', () => {
5253
environment: 'test',
5354
params: {},
5455
screenshots: 'on',
56+
otel: false,
5557
});
5658
expect(await normalizeOptions(cliArgs)).toMatchObject({
5759
dryRun: true,
60+
otel: true,
5861
environment: 'test',
5962
grepOpts: { match: 'check*' },
6063
params: {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2020-present, Elastic NV
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*
24+
*/
25+
26+
import { HrTime, metrics, SpanStatusCode } from '@opentelemetry/api';
27+
import { AttributeNames, DurationSpanProcessor } from '../../src/otel';
28+
import { ReadableSpan } from '@opentelemetry/sdk-trace-base';
29+
import {
30+
ATTR_URL_FULL,
31+
METRIC_HTTP_CLIENT_REQUEST_DURATION,
32+
} from '@opentelemetry/semantic-conventions';
33+
34+
const commonAttributes = {
35+
[AttributeNames.JOURNEY_ID]: 'journey-id',
36+
[AttributeNames.JOURNEY_NAME]: 'journey name',
37+
[AttributeNames.STEP_NAME]: 'step name',
38+
};
39+
const createSpan = (
40+
attributes: Record<string, any> = {},
41+
duration: HrTime = [0, 0.1 * 1e9],
42+
code: SpanStatusCode = SpanStatusCode.UNSET
43+
): ReadableSpan => {
44+
const span: Partial<ReadableSpan> = {
45+
duration,
46+
status: { code },
47+
attributes: {
48+
...commonAttributes,
49+
...attributes,
50+
'extra.attribute': 'extra attribute value',
51+
},
52+
};
53+
return span as ReadableSpan;
54+
};
55+
56+
describe('DurationSpanProcessor', () => {
57+
let records: [number, Record<string, any>][];
58+
const mockedRecord = (duration: number, attributes: Record<string, any>) => {
59+
records.push([duration, attributes]);
60+
};
61+
const mockedCreateGauge = jest.fn().mockReturnValue({ record: mockedRecord });
62+
const mockedCreateHistogram = jest
63+
.fn()
64+
.mockReturnValue({ record: mockedRecord });
65+
66+
jest.spyOn(metrics, 'getMeter').mockImplementation(
67+
() =>
68+
({
69+
createGauge: mockedCreateGauge,
70+
createHistogram: mockedCreateHistogram,
71+
} as any)
72+
);
73+
74+
beforeEach(() => {
75+
records = [];
76+
});
77+
78+
it('records gauge metrics for journeys and steps', () => {
79+
const spans: ReadableSpan[] = [
80+
createSpan({ [AttributeNames.SPAN_SUBTYPE]: 'journey' }, [0, 0.5 * 1e9]),
81+
createSpan({ [AttributeNames.SPAN_SUBTYPE]: 'step' }),
82+
createSpan(
83+
{ [AttributeNames.SPAN_SUBTYPE]: 'step' },
84+
[1, 0],
85+
SpanStatusCode.ERROR
86+
),
87+
createSpan({ [AttributeNames.SPAN_SUBTYPE]: 'other' }),
88+
];
89+
const spanProcessor = new DurationSpanProcessor();
90+
91+
spans.forEach(span => spanProcessor.onEnd(span));
92+
93+
expect(mockedCreateGauge).toBeCalledWith('synthetics.duration', {
94+
description: 'Duration in seconds',
95+
unit: 's',
96+
});
97+
expect(mockedCreateHistogram).not.toBeCalled();
98+
expect(records).toEqual([
99+
[
100+
0.5,
101+
{
102+
...commonAttributes,
103+
'synthetics.status': 'success',
104+
'synthetics.type': 'journey',
105+
},
106+
],
107+
[
108+
0.1,
109+
{
110+
...commonAttributes,
111+
'synthetics.status': 'success',
112+
'synthetics.type': 'step',
113+
},
114+
],
115+
[
116+
1.0,
117+
{
118+
...commonAttributes,
119+
'synthetics.status': 'error',
120+
'synthetics.type': 'step',
121+
},
122+
],
123+
]);
124+
});
125+
126+
it('records histogram metrics for requests', () => {
127+
const spans: ReadableSpan[] = [
128+
createSpan({ [ATTR_URL_FULL]: 'https://example.com/first' }, [
129+
0,
130+
0.5 * 1e9,
131+
]),
132+
createSpan({
133+
[ATTR_URL_FULL]: 'https://example.com/second',
134+
}),
135+
createSpan({
136+
'span-without-url': 'value',
137+
}),
138+
];
139+
const spanProcessor = new DurationSpanProcessor();
140+
141+
spans.forEach(span => spanProcessor.onEnd(span));
142+
143+
expect(mockedCreateGauge).not.toBeCalled();
144+
expect(mockedCreateHistogram).toBeCalledWith(
145+
METRIC_HTTP_CLIENT_REQUEST_DURATION,
146+
{
147+
description: 'Duration in seconds for HTTP server spans',
148+
unit: 's',
149+
}
150+
);
151+
expect(records).toEqual([
152+
[0.5, commonAttributes],
153+
[0.1, commonAttributes],
154+
]);
155+
});
156+
});

0 commit comments

Comments
 (0)