Skip to content

Commit fe893e2

Browse files
feat: gRPC matchers (#48)
1 parent a543845 commit fe893e2

File tree

15 files changed

+425
-7
lines changed

15 files changed

+425
-7
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
jest.config.js
22
tsconfig.json
33
dist/
4+
packages/otel-proto

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
*.proto
22
dist/
3+
packages/otel-proto

package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/expect-opentelemetry/src/index.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
import { toReceiveHttpRequest } from './matchers/service/to-receive-http-request';
2-
import { toSendHttpRequest } from './matchers/service/to-send-http-request';
3-
import { toQueryPostgreSQL } from './matchers/service/to-query-postgresql';
1+
import {
2+
toReceiveHttpRequest,
3+
toSendHttpRequest,
4+
toQueryPostgreSQL,
5+
toReceiveGrpcRequest,
6+
toSendGrpcRequest,
7+
} from './matchers/service';
48
import { expect } from '@jest/globals';
5-
import { HttpRequest, PostgreSQLQuery, Service } from './resources';
9+
import {
10+
GrpcRequest,
11+
HttpRequest,
12+
PostgreSQLQuery,
13+
Service,
14+
} from './resources';
615
export { setDefaultOptions, getDefaultOptions } from './options';
716

817
export * from './matchers';
@@ -13,12 +22,16 @@ const serviceMatchers = {
1322
toReceiveHttpRequest,
1423
toSendHttpRequest,
1524
toQueryPostgreSQL,
25+
toReceiveGrpcRequest,
26+
toSendGrpcRequest,
1627
};
1728

1829
interface TraceMatchers {
1930
toReceiveHttpRequest(): HttpRequest;
2031
toSendHttpRequest(): HttpRequest;
2132
toQueryPostgreSQL(): PostgreSQLQuery;
33+
toReceiveGrpcRequest(): GrpcRequest;
34+
toSendGrpcRequest(): GrpcRequest;
2235
}
2336

2437
function createMatcher(matcher, type) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { jest, describe, it } from '@jest/globals';
2+
import { expectTrace } from '../..';
3+
import { TraceLoop } from '../../trace-loop';
4+
5+
jest.setTimeout(30000);
6+
7+
describe('grpc request matchers', () => {
8+
describe('when orders-service makes a gRPC call to the grpc server', () => {
9+
let traceloop: TraceLoop;
10+
beforeAll(async () => {
11+
traceloop = new TraceLoop();
12+
13+
await traceloop.axiosInstance.post('http://localhost:3000/orders/create');
14+
await traceloop.fetchTraces();
15+
});
16+
17+
it('should contain outbound gRPC call from orders-service', async () => {
18+
expectTrace(
19+
traceloop.serviceByName('orders-service'),
20+
).toSendGrpcRequest();
21+
});
22+
23+
it('should contain inbound gRPC call to grpc service', async () => {
24+
expectTrace(
25+
traceloop.serviceByName('grpc-service'),
26+
).toReceiveGrpcRequest();
27+
});
28+
});
29+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from './to-receive-http-request';
22
export * from './to-send-http-request';
33
export * from './to-query-postgresql';
4+
export * from './to-receive-grpc-request';
5+
export * from './to-send-grpc-request';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
2+
import { Service } from '../../resources/service';
3+
import { opentelemetry } from '@traceloop/otel-proto';
4+
import { GrpcRequest } from '../../resources';
5+
6+
export function toReceiveGrpcRequest(service: Service): GrpcRequest {
7+
const { name: serviceName, spans } = service;
8+
const spanKind = opentelemetry.proto.trace.v1.Span.SpanKind.SPAN_KIND_SERVER;
9+
10+
const filteredSpans = spans.filter(
11+
(span: opentelemetry.proto.trace.v1.ISpan) => {
12+
return (
13+
span.kind === spanKind &&
14+
span.attributes?.find(
15+
(attribute: opentelemetry.proto.common.v1.IKeyValue) => {
16+
return (
17+
attribute.key === SemanticAttributes.RPC_SYSTEM &&
18+
attribute.value?.stringValue === 'grpc'
19+
);
20+
},
21+
)
22+
);
23+
},
24+
);
25+
26+
if (filteredSpans.length === 0) {
27+
throw new Error(`No gRPC call was received by ${serviceName}`);
28+
}
29+
30+
return new GrpcRequest(filteredSpans, { serviceName, spanKind });
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
2+
import { Service } from '../../resources/service';
3+
import { opentelemetry } from '@traceloop/otel-proto';
4+
import { GrpcRequest } from '../../resources';
5+
6+
export function toSendGrpcRequest(service: Service): GrpcRequest {
7+
const { name: serviceName, spans } = service;
8+
const spanKind = opentelemetry.proto.trace.v1.Span.SpanKind.SPAN_KIND_CLIENT;
9+
10+
const filteredSpans = spans.filter(
11+
(span: opentelemetry.proto.trace.v1.ISpan) => {
12+
return (
13+
span.kind === spanKind &&
14+
span.attributes?.find(
15+
(attribute: opentelemetry.proto.common.v1.IKeyValue) => {
16+
return (
17+
attribute.key === SemanticAttributes.RPC_SYSTEM &&
18+
attribute.value?.stringValue === 'grpc'
19+
);
20+
},
21+
)
22+
);
23+
},
24+
);
25+
26+
if (filteredSpans.length === 0) {
27+
throw new Error(`No gRPC call was sent by ${serviceName}`);
28+
}
29+
30+
return new GrpcRequest(filteredSpans, { serviceName, spanKind });
31+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { jest, describe, it } from '@jest/globals';
2+
import { expectTrace } from '../..';
3+
import { TraceLoop } from '../trace-loop';
4+
import { GrpcRequest } from '../resources/grpc-request';
5+
6+
jest.setTimeout(30000);
7+
8+
describe('resource grpc request matchers', () => {
9+
describe('when orders-service makes a gRPC call to grpc-service', () => {
10+
let traceloop: TraceLoop;
11+
beforeAll(async () => {
12+
traceloop = new TraceLoop();
13+
14+
await traceloop.axiosInstance.post('http://localhost:3000/orders/create');
15+
await traceloop.fetchTraces();
16+
});
17+
18+
it('should contain outbound grpc call from orders-service with all parameters', async () => {
19+
expectTrace(traceloop.serviceByName('orders-service'))
20+
.toSendGrpcRequest()
21+
.withRpcMethod('SayHello')
22+
.withRpcService('Greeter', { compareType: 'contains' })
23+
.withRpcGrpcStatusCode(GrpcRequest.GRPC_STATUS_CODE.OK);
24+
});
25+
26+
it('should contain inbound gRPC call to grpc-service', async () => {
27+
expectTrace(traceloop.serviceByName('grpc-service'))
28+
.toReceiveGrpcRequest()
29+
.withRpcMethod('SayHello')
30+
.withRpcService('Greeter', { compareType: 'contains' })
31+
.withRpcGrpcStatusCode(GrpcRequest.GRPC_STATUS_CODE.OK);
32+
});
33+
});
34+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
2+
import { opentelemetry } from '@traceloop/otel-proto';
3+
import {
4+
CompareOptions,
5+
filterByAttributeIntValue,
6+
filterByAttributeStringValue,
7+
} from '../matchers/utils';
8+
9+
const STATUS_CODE = {
10+
OK: 0,
11+
CANCELLED: 1,
12+
UNKNOWN: 2,
13+
INVALID_ARGUMENT: 3,
14+
DEADLINE_EXCEEDED: 4,
15+
NOT_FOUND: 5,
16+
ALREADY_EXISTS: 6,
17+
PERMISSION_DENIED: 7,
18+
RESOURCE_EXHAUSTED: 8,
19+
FAILED_PRECONDITION: 9,
20+
ABORTED: 10,
21+
OUT_OF_RANGE: 11,
22+
UNIMPLEMENTED: 12,
23+
INTERNAL: 13,
24+
UNAVAILABLE: 14,
25+
DATA_LOSS: 15,
26+
UNAUTHENTICATED: 16,
27+
} as const;
28+
29+
type StatusCode = (typeof STATUS_CODE)[keyof typeof STATUS_CODE];
30+
31+
export class GrpcRequest {
32+
static readonly GRPC_STATUS_CODE = STATUS_CODE;
33+
34+
constructor(
35+
readonly spans: opentelemetry.proto.trace.v1.ISpan[],
36+
readonly extra: {
37+
serviceName: string;
38+
spanKind: opentelemetry.proto.trace.v1.Span.SpanKind;
39+
},
40+
) {}
41+
42+
withRpcMethod(method: string, options?: CompareOptions) {
43+
const filteredSpans = filterByAttributeStringValue(
44+
this.spans,
45+
SemanticAttributes.RPC_METHOD,
46+
method,
47+
options,
48+
);
49+
50+
if (filteredSpans.length === 0) {
51+
throw new Error(
52+
`No gRPC call of method ${method} ${this.serviceErrorBySpanKind()}`,
53+
);
54+
}
55+
56+
return new GrpcRequest(filteredSpans, this.extra);
57+
}
58+
59+
withRpcService(service: string, options?: CompareOptions) {
60+
const filteredSpans = filterByAttributeStringValue(
61+
this.spans,
62+
SemanticAttributes.RPC_SERVICE,
63+
service,
64+
options,
65+
);
66+
67+
if (filteredSpans.length === 0) {
68+
throw new Error(
69+
`No gRPC call for service ${service} ${this.serviceErrorBySpanKind()}`,
70+
);
71+
}
72+
73+
return new GrpcRequest(filteredSpans, this.extra);
74+
}
75+
76+
withRpcGrpcStatusCode(code: StatusCode) {
77+
const filteredSpans = filterByAttributeIntValue(
78+
this.spans,
79+
SemanticAttributes.RPC_GRPC_STATUS_CODE,
80+
code,
81+
);
82+
83+
// spec says it should be an int, but in practice we get strings
84+
const filteredSpansString = filterByAttributeStringValue(
85+
this.spans,
86+
SemanticAttributes.RPC_GRPC_STATUS_CODE,
87+
code.toString(),
88+
);
89+
90+
if (filteredSpans.length === 0 && filteredSpansString.length === 0) {
91+
throw new Error(
92+
`No gRPC call with status code ${code} ${this.serviceErrorBySpanKind()}`,
93+
);
94+
}
95+
96+
return new GrpcRequest(
97+
filteredSpans.length !== 0 ? filteredSpans : filteredSpansString,
98+
this.extra,
99+
);
100+
}
101+
102+
withNetPeerName(netPeerName: string, options?: CompareOptions) {
103+
const filteredSpans = filterByAttributeStringValue(
104+
this.spans,
105+
SemanticAttributes.NET_PEER_NAME,
106+
netPeerName,
107+
options,
108+
);
109+
110+
if (filteredSpans.length === 0) {
111+
throw new Error(
112+
`No gRPC call with net peer name (host name) ${netPeerName} ${this.serviceErrorBySpanKind()}`,
113+
);
114+
}
115+
116+
return new GrpcRequest(filteredSpans, this.extra);
117+
}
118+
119+
withHostName(hostName: string, options?: CompareOptions) {
120+
return this.withNetPeerName(hostName, options);
121+
}
122+
123+
private serviceErrorBySpanKind() {
124+
const { SPAN_KIND_CLIENT, SPAN_KIND_SERVER } =
125+
opentelemetry.proto.trace.v1.Span.SpanKind;
126+
const { spanKind, serviceName } = this.extra;
127+
128+
switch (spanKind) {
129+
case SPAN_KIND_CLIENT:
130+
return `was sent by ${serviceName}`;
131+
case SPAN_KIND_SERVER:
132+
return `was received by ${serviceName}`;
133+
default:
134+
return `was found for ${serviceName}`;
135+
}
136+
}
137+
}

0 commit comments

Comments
 (0)