Skip to content

Commit 872e93c

Browse files
feat(opentelemetry): create otel instrumentation for typed-express-router
2 parents a203328 + 296a92e commit 872e93c

File tree

10 files changed

+1520
-358
lines changed

10 files changed

+1520
-358
lines changed

package-lock.json

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

packages/express-wrapper/src/index.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,22 @@
66
import express from 'express';
77

88
import { ApiSpec, HttpRoute, Method as HttpMethod } from '@api-ts/io-ts-http';
9-
import { createRouter } from '@api-ts/typed-express-router';
9+
import {
10+
createRouter,
11+
DecodeErrorFormatterFn,
12+
EncodeErrorFormatterFn,
13+
GetDecodeErrorStatusCodeFn,
14+
GetEncodeErrorStatusCodeFn,
15+
} from '@api-ts/typed-express-router';
1016

11-
import { handleRequest, onDecodeError, onEncodeError, RouteHandler } from './request';
17+
import {
18+
handleRequest,
19+
defaultDecodeErrorFormatter,
20+
defaultEncodeErrorFormatter,
21+
defaultGetDecodeErrorStatusCode,
22+
defaultGetEncodeErrorStatusCode,
23+
RouteHandler,
24+
} from './request';
1225
import { defaultResponseEncoder, ResponseEncoder } from './response';
1326

1427
export { middlewareFn, MiddlewareChain, MiddlewareChainOutput } from './middleware';
@@ -25,16 +38,26 @@ type CreateRouterProps<Spec extends ApiSpec> = {
2538
};
2639
};
2740
encoder?: ResponseEncoder;
41+
decodeErrorFormatter?: DecodeErrorFormatterFn;
42+
encodeErrorFormatter?: EncodeErrorFormatterFn;
43+
getDecodeErrorStatusCode?: GetDecodeErrorStatusCodeFn;
44+
getEncodeErrorStatusCode?: GetEncodeErrorStatusCodeFn;
2845
};
2946

3047
export function routerForApiSpec<Spec extends ApiSpec>({
3148
spec,
3249
routeHandlers,
3350
encoder = defaultResponseEncoder,
51+
decodeErrorFormatter = defaultDecodeErrorFormatter,
52+
encodeErrorFormatter = defaultEncodeErrorFormatter,
53+
getDecodeErrorStatusCode = defaultGetDecodeErrorStatusCode,
54+
getEncodeErrorStatusCode = defaultGetEncodeErrorStatusCode,
3455
}: CreateRouterProps<Spec>) {
3556
const router = createRouter(spec, {
36-
onDecodeError,
37-
onEncodeError,
57+
decodeErrorFormatter,
58+
encodeErrorFormatter,
59+
getDecodeErrorStatusCode,
60+
getEncodeErrorStatusCode,
3861
});
3962
for (const apiName of Object.keys(spec)) {
4063
const resource = spec[apiName] as Spec[string];

packages/express-wrapper/src/request.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import * as PathReporter from 'io-ts/lib/PathReporter';
88

99
import { ApiSpec, HttpRoute, RequestType, ResponseType } from '@api-ts/io-ts-http';
1010
import {
11-
OnDecodeErrorFn,
12-
OnEncodeErrorFn,
11+
type DecodeErrorFormatterFn,
12+
type EncodeErrorFormatterFn,
13+
type GetDecodeErrorStatusCodeFn,
14+
type GetEncodeErrorStatusCodeFn,
1315
TypedRequestHandler,
1416
} from '@api-ts/typed-express-router';
1517

@@ -94,17 +96,28 @@ const createNamedFunction = <F extends (...args: any) => void>(
9496
fn: F,
9597
): F => Object.defineProperty(fn, 'name', { value: name });
9698

97-
export const onDecodeError: OnDecodeErrorFn = (errs, _req, res) => {
99+
export const defaultDecodeErrorFormatter: DecodeErrorFormatterFn = (errs, _req) => {
98100
const validationErrors = PathReporter.failure(errs);
99-
const validationErrorMessage = validationErrors.join('\n');
100-
res.writeHead(400, { 'Content-Type': 'application/json' });
101-
res.write(JSON.stringify({ error: validationErrorMessage }));
102-
res.end();
101+
return { error: validationErrors.join('\n') };
103102
};
104103

105-
export const onEncodeError: OnEncodeErrorFn = (err, _req, res) => {
104+
export const defaultEncodeErrorFormatter: EncodeErrorFormatterFn = (_err, _req) => {
105+
return {};
106+
};
107+
108+
export const defaultGetDecodeErrorStatusCode: GetDecodeErrorStatusCodeFn = (
109+
_errs,
110+
_req,
111+
) => {
112+
return 400;
113+
};
114+
115+
export const defaultGetEncodeErrorStatusCode: GetEncodeErrorStatusCodeFn = (
116+
err,
117+
_req,
118+
) => {
106119
console.warn('Error in route handler:', err);
107-
res.status(500).end();
120+
return 500;
108121
};
109122

110123
export const handleRequest = (

packages/typed-express-router/package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,22 @@
2323
"fp-ts": "^2.0.0",
2424
"io-ts": "2.1.3"
2525
},
26+
"peerDependencies": {
27+
"@opentelemetry/api": "^1.0.0"
28+
},
29+
"peerDependenciesMeta": {
30+
"@opentelemetry/api": {
31+
"optional": true
32+
}
33+
},
2634
"devDependencies": {
2735
"@api-ts/superagent-wrapper": "0.0.0-semantically-released",
2836
"@swc-node/register": "1.10.9",
2937
"c8": "10.1.3",
30-
"typescript": "4.7.4"
38+
"typescript": "4.7.4",
39+
"@opentelemetry/sdk-trace-base": "1.30.1",
40+
"@opentelemetry/sdk-trace-node": "1.30.1",
41+
"@opentelemetry/api": "1.9.0"
3142
},
3243
"publishConfig": {
3344
"access": "public"
Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
1-
import express from 'express';
2-
import { Errors } from 'io-ts';
31
import * as PathReporter from 'io-ts/lib/PathReporter';
42

5-
export function defaultOnDecodeError(
6-
errs: Errors,
7-
_req: express.Request,
8-
res: express.Response,
9-
) {
10-
const validationErrors = PathReporter.failure(errs);
11-
const validationErrorMessage = validationErrors.join('\n');
12-
res.status(400).json({ error: validationErrorMessage }).end();
13-
}
3+
import type {
4+
DecodeErrorFormatterFn,
5+
EncodeErrorFormatterFn,
6+
GetDecodeErrorStatusCodeFn,
7+
GetEncodeErrorStatusCodeFn,
8+
} from './types';
149

15-
export function defaultOnEncodeError(
16-
err: unknown,
17-
_req: express.Request,
18-
res: express.Response,
19-
) {
20-
res.status(500).end();
21-
console.warn(`Error in route handler: ${err}`);
22-
}
10+
export const defaultDecodeErrorFormatter: DecodeErrorFormatterFn = PathReporter.failure;
11+
12+
export const defaultGetDecodeErrorStatusCode: GetDecodeErrorStatusCodeFn = (
13+
_err,
14+
_req,
15+
) => 400;
16+
17+
export const defaultEncodeErrorFormatter: EncodeErrorFormatterFn = () => ({});
18+
19+
export const defaultGetEncodeErrorStatusCode: GetEncodeErrorStatusCodeFn = (
20+
_err,
21+
_req,
22+
) => 500;

packages/typed-express-router/src/index.ts

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,27 @@
33
*/
44

55
import { ApiSpec, HttpRoute, KeyToHttpStatus } from '@api-ts/io-ts-http';
6+
import type { Span } from '@opentelemetry/api';
67
import express from 'express';
78
import * as E from 'fp-ts/Either';
89
import { pipe } from 'fp-ts/pipeable';
9-
import { defaultOnDecodeError, defaultOnEncodeError } from './errors';
10+
11+
import {
12+
defaultDecodeErrorFormatter,
13+
defaultEncodeErrorFormatter,
14+
defaultGetDecodeErrorStatusCode,
15+
defaultGetEncodeErrorStatusCode,
16+
} from './errors';
1017
import { apiTsPathToExpress } from './path';
18+
import {
19+
ApiTsAttributes,
20+
createDecodeSpan,
21+
createSendEncodedSpan,
22+
endSpan,
23+
recordSpanDecodeError,
24+
recordSpanEncodeError,
25+
setSpanAttributes,
26+
} from './telemetry';
1127
import {
1228
AddRouteHandler,
1329
AddUncheckedRouteHandler,
@@ -22,8 +38,10 @@ import {
2238

2339
export type {
2440
AfterEncodedResponseSentFn,
25-
OnDecodeErrorFn,
26-
OnEncodeErrorFn,
41+
DecodeErrorFormatterFn,
42+
EncodeErrorFormatterFn,
43+
GetDecodeErrorStatusCodeFn,
44+
GetEncodeErrorStatusCodeFn,
2745
TypedRequestHandler,
2846
UncheckedRequestHandler,
2947
WrappedRouter,
@@ -43,17 +61,21 @@ export type {
4361
export function createRouter<Spec extends ApiSpec>(
4462
spec: Spec,
4563
{
46-
onDecodeError,
47-
onEncodeError,
64+
encodeErrorFormatter,
65+
getEncodeErrorStatusCode,
4866
afterEncodedResponseSent,
67+
decodeErrorFormatter,
68+
getDecodeErrorStatusCode,
4969
...options
5070
}: WrappedRouterOptions = {},
5171
): WrappedRouter<Spec> {
5272
const router = express.Router(options);
5373
return wrapRouter(router, spec, {
54-
onDecodeError,
55-
onEncodeError,
74+
encodeErrorFormatter,
75+
getEncodeErrorStatusCode,
5676
afterEncodedResponseSent,
77+
decodeErrorFormatter,
78+
getDecodeErrorStatusCode,
5779
});
5880
}
5981

@@ -69,9 +91,11 @@ export function wrapRouter<Spec extends ApiSpec>(
6991
router: express.Router,
7092
spec: Spec,
7193
{
72-
onDecodeError = defaultOnDecodeError,
73-
onEncodeError = defaultOnEncodeError,
94+
encodeErrorFormatter = defaultEncodeErrorFormatter,
95+
getEncodeErrorStatusCode = defaultGetEncodeErrorStatusCode,
7496
afterEncodedResponseSent = () => {},
97+
decodeErrorFormatter = defaultDecodeErrorFormatter,
98+
getDecodeErrorStatusCode = defaultGetDecodeErrorStatusCode,
7599
}: WrappedRouteOptions,
76100
): WrappedRouter<Spec> {
77101
const routerMiddleware: UncheckedRequestHandler[] = [];
@@ -81,12 +105,14 @@ export function wrapRouter<Spec extends ApiSpec>(
81105
): AddUncheckedRouteHandler<Spec, Method> {
82106
return (apiName, handlers, options) => {
83107
const route: HttpRoute | undefined = spec[apiName]?.[method];
108+
let decodeSpan: Span | undefined;
84109
if (route === undefined) {
85110
// Should only happen with an explicit undefined property, which we can only prevent at the
86111
// type level with the `exactOptionalPropertyTypes` tsconfig option
87112
throw Error(`Method "${method}" at "${apiName}" must not be "undefined"'`);
88113
}
89114
const wrapReqAndRes: UncheckedRequestHandler = (req, res, next) => {
115+
decodeSpan = createDecodeSpan({ apiName, httpRoute: route });
90116
// Intentionally passing explicit arguments here instead of decoding
91117
// req by itself because of issues that arise while using Node 16
92118
// See https://github.com/BitGo/api-ts/pull/394 for more information.
@@ -103,6 +129,10 @@ export function wrapRouter<Spec extends ApiSpec>(
103129
status: keyof (typeof route)['response'],
104130
payload: unknown,
105131
) => {
132+
const encodeSpan = createSendEncodedSpan({
133+
apiName,
134+
httpRoute: route,
135+
});
106136
try {
107137
const codec = route.response[status];
108138
if (!codec) {
@@ -112,6 +142,9 @@ export function wrapRouter<Spec extends ApiSpec>(
112142
typeof status === 'number'
113143
? status
114144
: KeyToHttpStatus[status as keyof KeyToHttpStatus];
145+
setSpanAttributes(encodeSpan, {
146+
[ApiTsAttributes.API_TS_STATUS_CODE]: statusCode,
147+
});
115148
if (statusCode === undefined) {
116149
throw new Error(`unknown HTTP status code for key ${status}`);
117150
} else if (!codec.is(payload)) {
@@ -126,18 +159,42 @@ export function wrapRouter<Spec extends ApiSpec>(
126159
res as WrappedResponse,
127160
);
128161
} catch (err) {
129-
(options?.onEncodeError ?? onEncodeError)(
130-
err,
131-
req as WrappedRequest,
132-
res as WrappedResponse,
133-
);
162+
const statusCode = (
163+
options?.getEncodeErrorStatusCode ?? getEncodeErrorStatusCode
164+
)(err, req);
165+
const encodeErrorMessage = (
166+
options?.encodeErrorFormatter ?? encodeErrorFormatter
167+
)(err, req);
168+
169+
recordSpanEncodeError(encodeSpan, err, statusCode);
170+
res.status(statusCode).json(encodeErrorMessage);
171+
} finally {
172+
endSpan(encodeSpan);
134173
}
135174
};
136175
next();
137176
};
138177

178+
const endDecodeSpanMiddleware: UncheckedRequestHandler = (req, _res, next) => {
179+
pipe(
180+
req.decoded,
181+
E.getOrElseW((errs) => {
182+
const decodeErrorMessage = (
183+
options?.decodeErrorFormatter ?? decodeErrorFormatter
184+
)(errs, req);
185+
const statusCode = (
186+
options?.getDecodeErrorStatusCode ?? getDecodeErrorStatusCode
187+
)(errs, req);
188+
recordSpanDecodeError(decodeSpan, decodeErrorMessage, statusCode);
189+
}),
190+
);
191+
endSpan(decodeSpan);
192+
next();
193+
};
194+
139195
const middlewareChain = [
140196
wrapReqAndRes,
197+
endDecodeSpanMiddleware,
141198
...routerMiddleware,
142199
...handlers,
143200
] as express.RequestHandler[];
@@ -164,7 +221,13 @@ export function wrapRouter<Spec extends ApiSpec>(
164221
req.decoded,
165222
E.matchW(
166223
(errs) => {
167-
(options?.onDecodeError ?? onDecodeError)(errs, req, res);
224+
const statusCode = (
225+
options?.getDecodeErrorStatusCode ?? getDecodeErrorStatusCode
226+
)(errs, req);
227+
const decodeErrorMessage = (
228+
options?.decodeErrorFormatter ?? decodeErrorFormatter
229+
)(errs, req);
230+
res.status(statusCode).json(decodeErrorMessage);
168231
},
169232
(value) => {
170233
req.decoded = value;

0 commit comments

Comments
 (0)