Skip to content

Commit

Permalink
feat: http matchers (#29)
Browse files Browse the repository at this point in the history
Co-authored-by: Nir Gazit <nir.gzt@gmail.com>
  • Loading branch information
tomer-friedman and nirga authored Feb 22, 2023
1 parent 4019942 commit 9c436c8
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 27 deletions.
2 changes: 1 addition & 1 deletion docs/jest-otel/syntax/services-rest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ expectTrace(traceloop.serviceByName('users-service'))
.toReceiveHttpRequest()
.ofMethod('POST')
.withHeader('Content-Type', 'application/json')
.withBody({
.withRequestBody({
id: 1,
name: 'John Doe',
address: '123 Main St',
Expand Down
11 changes: 7 additions & 4 deletions packages/expect-opentelemetry/src/matchers/utils/comparators.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import deepEqual from 'deep-equal';
import { expect } from '@jest/globals';
import { CompareOptions, COMPARE_TYPE } from './compare-types';

type MaybeString = string | undefined | null;
Expand All @@ -23,10 +24,12 @@ export const objectContains = (
a: Record<string, unknown>,
b: Record<string, unknown>,
): boolean => {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);

return bKeys.every((key) => aKeys.includes(key) && deepEqual(a[key], b[key]));
try {
expect(a).toMatchObject(b);
return true;
} catch (e) {
return false;
}
};

export const stringCompare = (
Expand Down
24 changes: 22 additions & 2 deletions packages/expect-opentelemetry/src/matchers/utils/filters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { opentelemetry } from '@traceloop/otel-proto';
import { stringCompare } from './comparators';
import { objectCompare, stringCompare } from './comparators';
import { CompareOptions } from './compare-types';

export const filterByAttributeKey = (
Expand Down Expand Up @@ -39,6 +39,26 @@ export const filterByAttributeIntValue = (
spans.filter((span) => {
return span.attributes?.find(
(attribute) =>
attribute.key === attName && attribute.value?.intValue === attValue,
attribute.key === attName &&
attribute.value?.intValue.toNumber() === attValue,
);
});

export const filterByAttributeJSON = (
spans: opentelemetry.proto.trace.v1.ISpan[],
attName: string,
attValue: Record<string, unknown>,
options?: CompareOptions,
) =>
spans.filter((span) => {
try {
const json = JSON.parse(
span.attributes?.find((attribute) => attribute.key === attName)?.value
?.stringValue || '',
);

return objectCompare(json, attValue, options);
} catch (e) {
return false;
}
});
42 changes: 42 additions & 0 deletions packages/expect-opentelemetry/src/resources/http-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { jest, describe, it } from '@jest/globals';
import { expectTrace } from '../..';
import { TraceLoop } from '../trace-loop';

jest.setTimeout(30000);

describe('resource http request matchers', () => {
describe('when orders-service makes an http call to emails-service', () => {
let traceloop: TraceLoop;
beforeAll(async () => {
traceloop = new TraceLoop();

await traceloop.axiosInstance.post('http://localhost:3000/orders/create');
await traceloop.fetchTraces();
});

it('should contain outbound http call from orders-service with all parameters', async () => {
expectTrace(traceloop.serviceByName('orders-service'))
.toSendHttpRequest()
.withMethod('POST')
.withUrl('/emails/send', { compareType: 'contains' })
.withRequestHeader('content-type', 'application/json')
.withRequestBody({
email: 'test',
nestedObject: { test: 'test' },
})
.withRequestBody(
{ nestedObject: { test: 'test' } },
{ compareType: 'contains' },
)
.withStatusCode(200);
});

it('should contain inbound http call to emails-service', async () => {
expectTrace(traceloop.serviceByName('emails-service'))
.toReceiveHttpRequest()
.withMethod('POST')
.withUrl('/emails/send', { compareType: 'contains' })
.withStatusCode(200);
});
});
});
109 changes: 91 additions & 18 deletions packages/expect-opentelemetry/src/resources/http-request.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import deepEqual from 'deep-equal';
import { opentelemetry } from '@traceloop/otel-proto';
import {
CompareOptions,
filterByAttributeIntValue,
filterByAttributeJSON,
filterByAttributeStringValue,
stringCompare,
} from '../matchers/utils';

export class HttpRequest {
Expand All @@ -16,52 +17,107 @@ export class HttpRequest {
},
) {}

withBody(body: object) {
const filteredSpans = this.spans.filter((span) => {
const jsonBody = JSON.parse(
span.attributes?.find(
(attribute) => attribute.key === 'http.request.body',
)?.value?.stringValue || '',
withRequestBody(body: Record<string, unknown>, options?: CompareOptions) {
const filteredSpans = filterByAttributeJSON(
this.spans,
'http.request.body',
body,
options,
);

if (filteredSpans.length === 0) {
throw new Error(
`No HTTP call with request body ${body} ${this.serviceErrorBySpanKind()}`,
);
}

return deepEqual(jsonBody, body);
});
return new HttpRequest(filteredSpans, this.extra);
}

withResponseBody(body: Record<string, unknown>, options?: CompareOptions) {
const filteredSpans = filterByAttributeJSON(
this.spans,
'http.response.body',
body,
options,
);

if (filteredSpans.length === 0) {
throw new Error(
`No HTTP call with body ${body} ${this.serviceErrorBySpanKind()}`,
`No HTTP call with response body ${body} ${this.serviceErrorBySpanKind()}`,
);
}

return new HttpRequest(filteredSpans, this.extra);
}

withHeader(key: string, value: string, options: CompareOptions) {
const filteredSpans = filterByAttributeStringValue(
withRequestHeader(key: string, value: string, options?: CompareOptions) {
const filteredSpansBySingle = filterByAttributeStringValue(
this.spans,
`http.request.header.${key}`,
value,
options,
);

const headerObjectSpans = this.spans.filter((span) => {
const attr = span.attributes?.find(
(attribute) => attribute.key === 'http.request.headers',
);
try {
const headerObject = JSON.parse(attr?.value?.stringValue ?? '');
return stringCompare(headerObject[key], value);
} catch (e) {
return false;
}
});

const filteredSpans = [...filteredSpansBySingle, ...headerObjectSpans];

if (filteredSpans.length === 0) {
throw new Error(
`No HTTP call with request header ${key} assigned with ${value} ${this.serviceErrorBySpanKind()}`,
);
}

return new HttpRequest(filteredSpans, this.extra);
}

withResponseHeader(key: string, value: string, options?: CompareOptions) {
const filteredSpansBySingle = filterByAttributeStringValue(
this.spans,
`http.response.header.${key}`,
value,
options,
);

const headerObjectSpans = this.spans.filter((span) => {
const attr = span.attributes?.find(
(attribute) => attribute.key === 'http.response.headers',
);
try {
const headerObject = JSON.parse(attr?.value?.stringValue ?? '');
return stringCompare(headerObject[key], value);
} catch (e) {
return false;
}
});

const filteredSpans = [...filteredSpansBySingle, ...headerObjectSpans];

if (filteredSpans.length === 0) {
throw new Error(
`No HTTP call with header ${key} assigned with ${value} ${this.serviceErrorBySpanKind()}`,
`No HTTP call with response header ${key} assigned with ${value} ${this.serviceErrorBySpanKind()}`,
);
}

return new HttpRequest(filteredSpans, this.extra);
}

ofMethod(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
options?: CompareOptions,
) {
withMethod(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH') {
const filteredSpans = filterByAttributeStringValue(
this.spans,
SemanticAttributes.HTTP_METHOD,
method,
options,
);

if (filteredSpans.length === 0) {
Expand Down Expand Up @@ -89,6 +145,23 @@ export class HttpRequest {
return new HttpRequest(filteredSpans, this.extra);
}

withUrl(url: string, options?: CompareOptions) {
const filteredSpans = filterByAttributeStringValue(
this.spans,
SemanticAttributes.HTTP_URL,
url,
options,
);

if (filteredSpans.length === 0) {
throw new Error(
`No HTTP call with url ${url} ${this.serviceErrorBySpanKind()}`,
);
}

return new HttpRequest(filteredSpans, this.extra);
}

private serviceErrorBySpanKind() {
switch (this.extra.spanKind) {
case opentelemetry.proto.trace.v1.Span.SpanKind.SPAN_KIND_CLIENT:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface FetchTracesConfig {
export const fetchTracesConfigBase: FetchTracesConfig = {
maxPollTime: 10000,
pollInterval: 500,
awaitAllTracesTimeout: 2000,
awaitAllTracesTimeout: 4000,
url: 'http://localhost:4123/v1/traces',
};

Expand Down
5 changes: 4 additions & 1 deletion packages/test-servers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ ordersService.post('/orders/create', async (req, res) => {
console.log('Order created! Sending email...');
const EMAILS_SERVICE_URL =
process.env.EMAILS_SERVICE_URL || 'http://localhost:3001';
await axios.post(`${EMAILS_SERVICE_URL}/emails/send`);
await axios.post(`${EMAILS_SERVICE_URL}/emails/send`, {
email: 'test',
nestedObject: { test: 'test' },
});

res.send('Order created!');
});
Expand Down

0 comments on commit 9c436c8

Please sign in to comment.