@openapi-typescript-infra/service 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/.eslintignore +7 -0
  2. package/.eslintrc.js +14 -0
  3. package/.github/workflows/codeql-analysis.yml +74 -0
  4. package/.github/workflows/nodejs.yml +23 -0
  5. package/.github/workflows/npmpublish.yml +35 -0
  6. package/.husky/pre-commit +6 -0
  7. package/.prettierrc.js +14 -0
  8. package/@types/config.d.ts +56 -0
  9. package/CHANGELOG.md +5 -0
  10. package/LICENSE +21 -0
  11. package/README.md +28 -0
  12. package/SECURITY.md +12 -0
  13. package/__tests__/config.test.ts +31 -0
  14. package/__tests__/fake-serv/api/fake-serv.yaml +48 -0
  15. package/__tests__/fake-serv/config/config.json +15 -0
  16. package/__tests__/fake-serv/src/handlers/hello.ts +10 -0
  17. package/__tests__/fake-serv/src/index.ts +29 -0
  18. package/__tests__/fake-serv/src/routes/error.ts +13 -0
  19. package/__tests__/fake-serv/src/routes/index.ts +22 -0
  20. package/__tests__/fake-serv/src/routes/other/world.ts +7 -0
  21. package/__tests__/fake-serv.test.ts +74 -0
  22. package/build/bin/start-service.d.ts +2 -0
  23. package/build/bin/start-service.js +31 -0
  24. package/build/bin/start-service.js.map +1 -0
  25. package/build/bootstrap.d.ts +16 -0
  26. package/build/bootstrap.js +90 -0
  27. package/build/bootstrap.js.map +1 -0
  28. package/build/config/index.d.ts +10 -0
  29. package/build/config/index.js +98 -0
  30. package/build/config/index.js.map +1 -0
  31. package/build/config/schema.d.ts +48 -0
  32. package/build/config/schema.js +3 -0
  33. package/build/config/schema.js.map +1 -0
  34. package/build/config/shortstops.d.ts +31 -0
  35. package/build/config/shortstops.js +109 -0
  36. package/build/config/shortstops.js.map +1 -0
  37. package/build/config/types.d.ts +3 -0
  38. package/build/config/types.js +3 -0
  39. package/build/config/types.js.map +1 -0
  40. package/build/development/port-finder.d.ts +1 -0
  41. package/build/development/port-finder.js +41 -0
  42. package/build/development/port-finder.js.map +1 -0
  43. package/build/development/repl.d.ts +2 -0
  44. package/build/development/repl.js +29 -0
  45. package/build/development/repl.js.map +1 -0
  46. package/build/env.d.ts +2 -0
  47. package/build/env.js +19 -0
  48. package/build/env.js.map +1 -0
  49. package/build/error.d.ts +25 -0
  50. package/build/error.js +28 -0
  51. package/build/error.js.map +1 -0
  52. package/build/express-app/app.d.ts +6 -0
  53. package/build/express-app/app.js +327 -0
  54. package/build/express-app/app.js.map +1 -0
  55. package/build/express-app/index.d.ts +2 -0
  56. package/build/express-app/index.js +19 -0
  57. package/build/express-app/index.js.map +1 -0
  58. package/build/express-app/internal-server.d.ts +3 -0
  59. package/build/express-app/internal-server.js +34 -0
  60. package/build/express-app/internal-server.js.map +1 -0
  61. package/build/express-app/route-loader.d.ts +2 -0
  62. package/build/express-app/route-loader.js +46 -0
  63. package/build/express-app/route-loader.js.map +1 -0
  64. package/build/express-app/types.d.ts +14 -0
  65. package/build/express-app/types.js +3 -0
  66. package/build/express-app/types.js.map +1 -0
  67. package/build/index.d.ts +8 -0
  68. package/build/index.js +25 -0
  69. package/build/index.js.map +1 -0
  70. package/build/openapi.d.ts +5 -0
  71. package/build/openapi.js +78 -0
  72. package/build/openapi.js.map +1 -0
  73. package/build/service-calls/index.d.ts +16 -0
  74. package/build/service-calls/index.js +85 -0
  75. package/build/service-calls/index.js.map +1 -0
  76. package/build/telemetry/fetchInstrumentation.d.ts +50 -0
  77. package/build/telemetry/fetchInstrumentation.js +144 -0
  78. package/build/telemetry/fetchInstrumentation.js.map +1 -0
  79. package/build/telemetry/index.d.ts +6 -0
  80. package/build/telemetry/index.js +80 -0
  81. package/build/telemetry/index.js.map +1 -0
  82. package/build/telemetry/instrumentations.d.ts +29 -0
  83. package/build/telemetry/instrumentations.js +47 -0
  84. package/build/telemetry/instrumentations.js.map +1 -0
  85. package/build/telemetry/requestLogger.d.ts +6 -0
  86. package/build/telemetry/requestLogger.js +144 -0
  87. package/build/telemetry/requestLogger.js.map +1 -0
  88. package/build/tsconfig.build.tsbuildinfo +1 -0
  89. package/build/types.d.ts +77 -0
  90. package/build/types.js +3 -0
  91. package/build/types.js.map +1 -0
  92. package/config/config.json +31 -0
  93. package/config/development.json +11 -0
  94. package/config/test.json +5 -0
  95. package/jest.config.js +14 -0
  96. package/package.json +111 -0
  97. package/src/bin/start-service.ts +28 -0
  98. package/src/bootstrap.ts +112 -0
  99. package/src/config/index.ts +115 -0
  100. package/src/config/schema.ts +66 -0
  101. package/src/config/shortstops.ts +118 -0
  102. package/src/config/types.ts +5 -0
  103. package/src/development/port-finder.ts +40 -0
  104. package/src/development/repl.ts +24 -0
  105. package/src/env.ts +14 -0
  106. package/src/error.ts +44 -0
  107. package/src/express-app/app.ts +399 -0
  108. package/src/express-app/index.ts +2 -0
  109. package/src/express-app/internal-server.ts +31 -0
  110. package/src/express-app/route-loader.ts +48 -0
  111. package/src/express-app/types.ts +31 -0
  112. package/src/index.ts +8 -0
  113. package/src/openapi.ts +67 -0
  114. package/src/service-calls/index.ts +129 -0
  115. package/src/telemetry/fetchInstrumentation.ts +209 -0
  116. package/src/telemetry/index.ts +69 -0
  117. package/src/telemetry/instrumentations.ts +54 -0
  118. package/src/telemetry/requestLogger.ts +193 -0
  119. package/src/types.ts +139 -0
  120. package/tsconfig.build.json +10 -0
  121. package/tsconfig.json +36 -0
@@ -0,0 +1,129 @@
1
+ import { URL } from 'node:url';
2
+
3
+ import type { FetchConfig, FetchRequest, RestApiResponse } from 'rest-api-support';
4
+ import EventSource from 'eventsource';
5
+
6
+ import { ServiceError, ServiceErrorSpec } from '../error';
7
+ import type {
8
+ ServiceExpress,
9
+ ServiceLike,
10
+ ServiceLocals,
11
+ } from '../types';
12
+ import type { ServiceConfiguration } from '../config/schema';
13
+
14
+ type UntypedEventSourceHandler = Parameters<EventSource['addEventListener']>[1];
15
+
16
+ class CustomEventSource extends EventSource {
17
+ private activeListeners: Array<{ handler: UntypedEventSourceHandler; name: string }> = [];
18
+
19
+ addEventListener<T>(name: string, handler: (data: MessageEvent<T>) => void): this {
20
+ super.addEventListener(name, handler);
21
+ this.activeListeners.push({ name, handler });
22
+ return this;
23
+ }
24
+
25
+ removeAllListeners() {
26
+ this.activeListeners.forEach((l) => {
27
+ super.removeEventListener(l.name as keyof EventSourceEventMap, l.handler);
28
+ });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Return a factory that will make instances of an OpenAPI/Swagger client for each request
34
+ */
35
+ export function createServiceInterface<ServiceType>(
36
+ service: ServiceExpress,
37
+ name: string,
38
+ Implementation: { new (c: FetchConfig): ServiceType },
39
+ ): ServiceType {
40
+ const appConfig = service.locals.config;
41
+ const config = {
42
+ ...(appConfig.get('connections:default') || {}),
43
+ ...(appConfig.get(`connections:${name}`) || {}),
44
+ } as ServiceConfiguration;
45
+ const protocol = config?.protocol || 'http';
46
+ const port = config?.port || 8000;
47
+ const host = config?.host || name;
48
+ const baseUrl = `${protocol}${protocol.endsWith(':') ? '//' : '://'}${host}:${port}${
49
+ config?.basePath || ''
50
+ }`;
51
+
52
+ const fetchConfig: FetchConfig = {
53
+ fetch,
54
+ AbortController,
55
+ EventSource: CustomEventSource,
56
+ FormData,
57
+ baseUrl,
58
+ };
59
+
60
+ // In development, it can be useful to route requests through
61
+ // a centralized local proxy (we use https://github.com/gas-buddy/container-proxy).
62
+ // This allows you to run a subset of services locally and route the rest
63
+ // of the requests to another (typically remote) environment.
64
+ if (config?.proxy) {
65
+ const proxyUrl = new URL(config.proxy);
66
+ const proxyPort = proxyUrl.protocol === 'https:' ? '8443' : '8000';
67
+
68
+ fetchConfig.requestInterceptor = (params: FetchRequest) => {
69
+ const parsedUrl = new URL(params.url);
70
+ const proto = parsedUrl.protocol.replace(/:$/, '');
71
+ const defaultPort = proto === 'https' ? 8443 : 8000;
72
+ const headers: FetchRequest['headers'] = {};
73
+ headers.host = `${proto}.${parsedUrl.hostname}.${port || defaultPort}`;
74
+ headers.source = service.locals.name;
75
+ parsedUrl.hostname = proxyUrl.hostname;
76
+ parsedUrl.protocol = proxyUrl.protocol;
77
+ parsedUrl.port = proxyUrl.port || proxyPort;
78
+ // eslint-disable-next-line no-param-reassign
79
+ params.headers = params.headers || {};
80
+ Object.assign(params.headers, headers);
81
+ // eslint-disable-next-line no-param-reassign
82
+ params.url = parsedUrl.href;
83
+ };
84
+ }
85
+
86
+ return new Implementation(fetchConfig);
87
+ }
88
+
89
+ interface SpecWithMessage extends ServiceErrorSpec {
90
+ message?: string;
91
+ }
92
+
93
+ function readResponse<
94
+ SLocals extends ServiceLocals,
95
+ AppType extends ServiceLike<SLocals>,
96
+ ResType extends RestApiResponse<number, SuccessResponseType>,
97
+ SuccessResponseType,
98
+ >(
99
+ app: AppType,
100
+ response: ResType,
101
+ errorSpec?: SpecWithMessage,
102
+ ): Extract<ResType, { responseType: 'response' }> {
103
+ if (response.responseType === 'response') {
104
+ return response as Extract<ResType, { responseType: 'response' }>;
105
+ }
106
+ const { message, ...spec } = errorSpec || {};
107
+ throw new ServiceError(
108
+ app,
109
+ message || (response.body as Error).message || 'Internal Error',
110
+ {
111
+ status: response.status,
112
+ ...spec,
113
+ },
114
+ );
115
+ }
116
+
117
+ export async function throwOrGetResponse<
118
+ SLocals extends ServiceLocals,
119
+ AppType extends ServiceLike<SLocals>,
120
+ ResType extends RestApiResponse<number, SuccessResponseType>,
121
+ SuccessResponseType,
122
+ >(
123
+ app: AppType,
124
+ exec: () => Promise<ResType>,
125
+ errorSpec?: SpecWithMessage,
126
+ ): Promise<Extract<ResType, { responseType: 'response' }>> {
127
+ const response = await exec();
128
+ return readResponse(app, response, errorSpec);
129
+ }
@@ -0,0 +1,209 @@
1
+ import diagch from 'node:diagnostics_channel';
2
+
3
+ import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
4
+ import { Instrumentation, InstrumentationConfig } from '@opentelemetry/instrumentation';
5
+ import {
6
+ Attributes,
7
+ context,
8
+ propagation,
9
+ Span,
10
+ SpanKind,
11
+ SpanStatusCode,
12
+ trace,
13
+ Tracer,
14
+ TracerProvider,
15
+ } from '@opentelemetry/api';
16
+ import { Meter, MeterProvider, metrics } from '@opentelemetry/api-metrics';
17
+
18
+ interface ListenerRecord {
19
+ name: string;
20
+ channel: diagch.Channel;
21
+ onMessage: diagch.ChannelListener;
22
+ }
23
+
24
+ interface FetchRequestArguments {
25
+ request: {
26
+ path: string;
27
+ method: string;
28
+ origin: string;
29
+ headers: string;
30
+ };
31
+ span: Span;
32
+ additionalHeaders: Record<string, unknown>;
33
+ error?: Error;
34
+ }
35
+
36
+ type ErrorFetchRequestArguments = FetchRequestArguments & { error: Error; };
37
+ type ResponseFetchRequestArguments = FetchRequestArguments & {
38
+ response: {
39
+ statusCode: number;
40
+ headers: Buffer[];
41
+ };
42
+ };
43
+
44
+ interface FetchInstrumentationConfig extends InstrumentationConfig {
45
+ onRequest?: (args: FetchRequestArguments) => void;
46
+ }
47
+
48
+ // Get the content-length from undici response headers.
49
+ // `headers` is an Array of buffers: [k, v, k, v, ...].
50
+ // If the header is not present, or has an invalid value, this returns null.
51
+ function contentLengthFromResponseHeaders(headers: Buffer[]) {
52
+ const name = 'content-length';
53
+ for (let i = 0; i < headers.length; i += 2) {
54
+ const k = headers[i];
55
+ if (k.length === name.length && k.toString().toLowerCase() === name) {
56
+ const v = Number(headers[i + 1]);
57
+ if (!Number.isNaN(Number(v))) {
58
+ return v;
59
+ }
60
+ return undefined;
61
+ }
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ // A combination of https://github.com/elastic/apm-agent-nodejs and
67
+ // https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts
68
+ export class FetchInstrumentation implements Instrumentation {
69
+ // Keep ref to avoid https://github.com/nodejs/node/issues/42170 bug and for
70
+ // unsubscribing.
71
+ private channelSubs: ListenerRecord[];
72
+
73
+ private spanFromReq = new WeakMap<object, Span>();
74
+
75
+ private tracer: Tracer;
76
+
77
+ private config: FetchInstrumentationConfig;
78
+
79
+ private meter: Meter;
80
+
81
+ public readonly instrumentationName = 'opentelemetry-instrumentation-node-18-fetch';
82
+
83
+ public readonly instrumentationVersion = '1.0.0';
84
+
85
+ public readonly instrumentationDescription = 'Instrumentation for Node 18 fetch via diagnostics_channel';
86
+
87
+ private subscribeToChannel(diagnosticChannel: string, onMessage: diagch.ChannelListener) {
88
+ const channel = diagch.channel(diagnosticChannel);
89
+ channel.subscribe(onMessage);
90
+ this.channelSubs.push({
91
+ name: diagnosticChannel,
92
+ channel,
93
+ onMessage,
94
+ });
95
+ }
96
+
97
+ constructor(config: FetchInstrumentationConfig) {
98
+ // Force load fetch API (since it's lazy loaded in Node 18)
99
+ fetch('').catch(Promise.resolve);
100
+ this.channelSubs = [];
101
+ this.meter = metrics.getMeter(this.instrumentationName, this.instrumentationVersion);
102
+ this.tracer = trace.getTracer(this.instrumentationName, this.instrumentationVersion);
103
+ this.config = { ...config };
104
+ }
105
+
106
+ disable(): void {
107
+ this.channelSubs?.forEach((sub) => sub.channel.unsubscribe(sub.onMessage));
108
+ }
109
+
110
+ enable(): void {
111
+ this.subscribeToChannel('undici:request:create', (args) => this.onRequest(args as FetchRequestArguments));
112
+ this.subscribeToChannel('undici:request:headers', (args) => this.onHeaders(args as ResponseFetchRequestArguments));
113
+ this.subscribeToChannel('undici:request:trailers', (args) => this.onDone(args as FetchRequestArguments));
114
+ this.subscribeToChannel('undici:request:error', (args) => this.onError(args as ErrorFetchRequestArguments));
115
+ }
116
+
117
+ setTracerProvider(tracerProvider: TracerProvider): void {
118
+ this.tracer = tracerProvider.getTracer(
119
+ this.instrumentationName,
120
+ this.instrumentationVersion,
121
+ );
122
+ }
123
+
124
+ public setMeterProvider(meterProvider: MeterProvider): void {
125
+ this.meter = meterProvider.getMeter(
126
+ this.instrumentationName,
127
+ this.instrumentationVersion,
128
+ );
129
+ }
130
+
131
+ setConfig(config: InstrumentationConfig): void {
132
+ this.config = { ...config };
133
+ }
134
+
135
+ getConfig(): InstrumentationConfig {
136
+ return this.config;
137
+ }
138
+
139
+ onRequest({ request }: FetchRequestArguments): void {
140
+ // We do not handle instrumenting HTTP CONNECT. See limitation notes above.
141
+ if (request.method === 'CONNECT') {
142
+ return;
143
+ }
144
+ const span = this.tracer.startSpan(`HTTP ${request.method}`, {
145
+ kind: SpanKind.CLIENT,
146
+ attributes: {
147
+ [SemanticAttributes.HTTP_URL]: String(request.origin),
148
+ [SemanticAttributes.HTTP_METHOD]: request.method,
149
+ [SemanticAttributes.HTTP_TARGET]: request.path,
150
+ 'http.client': 'fetch',
151
+ },
152
+ });
153
+ const requestContext = trace.setSpan(context.active(), span);
154
+ const addedHeaders: Record<string, string> = {};
155
+ propagation.inject(requestContext, addedHeaders);
156
+
157
+ if (this.config.onRequest) {
158
+ this.config.onRequest({ request, span, additionalHeaders: addedHeaders });
159
+ }
160
+
161
+ request.headers += Object.entries(addedHeaders)
162
+ .map(([k, v]) => `${k}: ${v}\r\n`)
163
+ .join('');
164
+ this.spanFromReq.set(request, span);
165
+ }
166
+
167
+ onHeaders({ request, response }: ResponseFetchRequestArguments): void {
168
+ const span = this.spanFromReq.get(request);
169
+
170
+ if (span !== undefined) {
171
+ // We are currently *not* capturing response headers, even though the
172
+ // intake API does allow it, because none of the other `setHttpContext`
173
+ // uses currently do.
174
+
175
+ const cLen = contentLengthFromResponseHeaders(response.headers);
176
+ const attrs: Attributes = {
177
+ [SemanticAttributes.HTTP_STATUS_CODE]: response.statusCode,
178
+ };
179
+ if (cLen) {
180
+ attrs[SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH] = cLen;
181
+ }
182
+ span.setAttributes(attrs);
183
+ span.setStatus({
184
+ code: response.statusCode >= 400 ? SpanStatusCode.ERROR : SpanStatusCode.OK,
185
+ message: String(response.statusCode),
186
+ });
187
+ }
188
+ }
189
+
190
+ onDone({ request }: FetchRequestArguments): void {
191
+ const span = this.spanFromReq.get(request);
192
+ if (span !== undefined) {
193
+ span.end();
194
+ this.spanFromReq.delete(request);
195
+ }
196
+ }
197
+
198
+ onError({ request, error }: ErrorFetchRequestArguments): void {
199
+ const span = this.spanFromReq.get(request);
200
+ if (span !== undefined) {
201
+ span.recordException(error);
202
+ span.setStatus({
203
+ code: SpanStatusCode.ERROR,
204
+ message: error.message,
205
+ });
206
+ span.end();
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,69 @@
1
+ import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
2
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
3
+ import * as opentelemetry from '@opentelemetry/sdk-node';
4
+
5
+ import type {
6
+ DelayLoadServiceStartOptions,
7
+ RequestLocals,
8
+ ServiceLocals,
9
+ ServiceStartOptions,
10
+ } from '../types';
11
+
12
+ import { getAutoInstrumentations } from './instrumentations';
13
+
14
+
15
+ // For troubleshooting, set the log level to DiagLogLevel.DEBUG
16
+ diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
17
+
18
+ function getExporter() {
19
+ if (['production', 'staging'].includes(process.env.APP_ENV || process.env.NODE_ENV || '')) {
20
+ return new OTLPTraceExporter({
21
+ url: process.env.OTLP_EXPORTER || 'http://otlp-exporter:4318/v1/traces',
22
+ });
23
+ }
24
+ return new opentelemetry.tracing.ConsoleSpanExporter();
25
+ }
26
+
27
+ export async function startWithTelemetry<
28
+ SLocals extends ServiceLocals = ServiceLocals,
29
+ RLocals extends RequestLocals = RequestLocals,
30
+ >(options: DelayLoadServiceStartOptions) {
31
+ const sdk = new opentelemetry.NodeSDK({
32
+ serviceName: options.name,
33
+ autoDetectResources: true,
34
+ traceExporter: getExporter(),
35
+ instrumentations: [getAutoInstrumentations({
36
+ 'opentelemetry-instrumentation-node-18-fetch': {
37
+ onRequest({ request, span, additionalHeaders }) {
38
+ // This particular line is "GasBuddy" specific, in that we have a number
39
+ // of services not yet on OpenTelemetry that look for this header instead.
40
+ // Putting traceId gives us a "shot in heck" of useful searches.
41
+ if (!/^correlationid:/m.test(request.headers)) {
42
+ const ctx = span.spanContext();
43
+ additionalHeaders.correlationid = ctx.traceId;
44
+ additionalHeaders.span = ctx.spanId;
45
+ }
46
+ },
47
+ },
48
+ })],
49
+ });
50
+ await sdk.start();
51
+
52
+ // eslint-disable-next-line import/no-unresolved
53
+ const { startApp, listen } = await import('../express-app/app.js');
54
+ // eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires
55
+ const { default: service } = require(options.service);
56
+ const startOptions: ServiceStartOptions<SLocals> = {
57
+ ...options,
58
+ service,
59
+ locals: { ...options.locals } as Partial<SLocals>,
60
+ };
61
+ const app = await startApp<SLocals, RLocals>(startOptions);
62
+ app.locals.logger.info('OpenTelemetry enabled');
63
+
64
+ const server = await listen(app, async () => {
65
+ await sdk.shutdown();
66
+ app.locals.logger.info('OpenTelemetry shut down');
67
+ });
68
+ return { app, server };
69
+ }
@@ -0,0 +1,54 @@
1
+ import type { Instrumentation } from '@opentelemetry/instrumentation';
2
+ import { AwsInstrumentation } from '@opentelemetry/instrumentation-aws-sdk';
3
+ import { DnsInstrumentation } from '@opentelemetry/instrumentation-dns';
4
+ import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
5
+ import { GenericPoolInstrumentation } from '@opentelemetry/instrumentation-generic-pool';
6
+ import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
7
+ import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
8
+ import { NetInstrumentation } from '@opentelemetry/instrumentation-net';
9
+ import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
10
+ import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
11
+
12
+ import { FetchInstrumentation } from './fetchInstrumentation';
13
+
14
+ const InstrumentationMap = {
15
+ // Disable this for now because it logs a stupid message
16
+ // '@opentelemetry/instrumentation-aws-lambda': AwsLambdaInstrumentation,
17
+ '@opentelemetry/instrumentation-http': HttpInstrumentation,
18
+ 'opentelemetry-instrumentation-node-18-fetch': FetchInstrumentation,
19
+ '@opentelemetry/instrumentation-aws-sdk': AwsInstrumentation,
20
+ '@opentelemetry/instrumentation-dns': DnsInstrumentation,
21
+ '@opentelemetry/instrumentation-express': ExpressInstrumentation,
22
+ '@opentelemetry/instrumentation-generic-pool': GenericPoolInstrumentation,
23
+ '@opentelemetry/instrumentation-ioredis': IORedisInstrumentation,
24
+ '@opentelemetry/instrumentation-net': NetInstrumentation,
25
+ '@opentelemetry/instrumentation-pg': PgInstrumentation,
26
+ '@opentelemetry/instrumentation-pino': PinoInstrumentation,
27
+ };
28
+
29
+ // Config types inferred automatically from the first argument of the constructor
30
+ type ConfigArg<T> = T extends new (...args: infer U) => unknown ? U[0] : never;
31
+ export type InstrumentationConfigMap = {
32
+ [Name in keyof typeof InstrumentationMap]?: ConfigArg<typeof InstrumentationMap[Name]>;
33
+ };
34
+
35
+ export function getAutoInstrumentations(
36
+ inputConfigs: InstrumentationConfigMap = {},
37
+ ): Instrumentation[] {
38
+ const keys = Object.keys(InstrumentationMap) as Array<keyof typeof InstrumentationMap>;
39
+ return keys
40
+ .map((name) => {
41
+ const Instance = InstrumentationMap[name];
42
+ // Defaults are defined by the instrumentation itself
43
+ const userConfig = inputConfigs[name] ?? {};
44
+
45
+ try {
46
+ return new Instance(userConfig);
47
+ } catch (e) {
48
+ // eslint-disable-next-line no-console
49
+ console.error(`Failed to load ${name}`, e);
50
+ return null;
51
+ }
52
+ })
53
+ .filter((i) => !!i) as Instrumentation[];
54
+ }
@@ -0,0 +1,193 @@
1
+ import type {
2
+ RequestHandler, Request, Response, ErrorRequestHandler,
3
+ } from 'express';
4
+
5
+ import { ServiceError } from '../error';
6
+ import type { RequestWithApp, ServiceExpress, ServiceLocals } from '../types';
7
+ import type { ServiceHandler } from '../express-app/types';
8
+
9
+ const LOG_PREFS = Symbol('Logging information');
10
+
11
+ interface LogPrefs {
12
+ start: [number, number];
13
+ logRequests?: boolean;
14
+ chunks?: Array<Buffer>;
15
+ logged: boolean;
16
+ }
17
+
18
+ interface WithLogPrefs {
19
+ [LOG_PREFS]: LogPrefs;
20
+ }
21
+
22
+ interface WithIdentifiedSession {
23
+ session?: {
24
+ id?: string;
25
+ };
26
+ }
27
+
28
+ interface ErrorWithStatus extends Error { status?: number; }
29
+
30
+ function getBasicInfo(req: Request) {
31
+ const url = req.originalUrl || req.url;
32
+
33
+ const preInfo: Record<string, string> = {
34
+ url,
35
+ m: req.method,
36
+ };
37
+
38
+ return preInfo;
39
+ }
40
+
41
+ function finishLog<SLocals extends ServiceLocals = ServiceLocals>(
42
+ app: ServiceExpress<SLocals>,
43
+ error: Error | undefined,
44
+ req: Request,
45
+ res: Response,
46
+ ) {
47
+ const prefs = (res.locals as WithLogPrefs)[LOG_PREFS];
48
+ if (prefs.logged) {
49
+ // This happens when error handler runs, but onEnd hasn't fired yet. We only log the first one.
50
+ return;
51
+ }
52
+
53
+ const { logger, service } = app.locals;
54
+ const hrdur = process.hrtime(prefs.start);
55
+
56
+ const dur = hrdur[0] + hrdur[1] / 1000000000;
57
+ const endLog: Record<string, string | string[] | number | undefined> = {
58
+ ...getBasicInfo(req),
59
+ s: (error as ErrorWithStatus)?.status || res.statusCode || 0,
60
+ dur,
61
+ };
62
+
63
+ if (res.locals.user?.id) {
64
+ endLog.u = res.locals.user.id;
65
+ }
66
+
67
+ if (error) {
68
+ endLog.e = error.message;
69
+ if (!(error instanceof ServiceError) || error.log_stack) {
70
+ endLog.st = error.stack;
71
+ }
72
+ }
73
+
74
+ if (prefs.logRequests) {
75
+ if (Buffer.isBuffer(req.body)) {
76
+ endLog.b = req.body.toString('base64');
77
+ } else if (typeof req.body !== 'string') {
78
+ endLog.b = JSON.stringify(req.body);
79
+ } else if (req.body) {
80
+ endLog.b = req.body;
81
+ }
82
+ }
83
+
84
+ if (prefs.chunks?.length) {
85
+ const bodyString = Buffer.concat(prefs.chunks).toString('utf8');
86
+ if (bodyString) {
87
+ endLog.resBody = bodyString;
88
+ }
89
+ }
90
+
91
+ service.getLogFields?.(req as RequestWithApp<SLocals>, endLog);
92
+ logger.info(endLog, 'req');
93
+ }
94
+
95
+ export function loggerMiddleware<SLocals extends ServiceLocals = ServiceLocals>(
96
+ app: ServiceExpress<SLocals>,
97
+ logRequests?: boolean,
98
+ logResponses?: boolean,
99
+ ): RequestHandler {
100
+ const { logger, service } = app.locals;
101
+ return function gblogger(req, res, next) {
102
+ const prefs: LogPrefs = {
103
+ start: process.hrtime(),
104
+ logRequests,
105
+ chunks: logResponses ? [] : undefined,
106
+ logged: false,
107
+ };
108
+
109
+ (res.locals as WithLogPrefs)[LOG_PREFS] = prefs;
110
+
111
+ if (logResponses) {
112
+ // res is a read-only stream, so the only way to intercept response
113
+ // data is to monkey-patch.
114
+ const oldWrite = res.write;
115
+ const oldEnd = res.end;
116
+ res.write = ((...args: Parameters<typeof res['write']>) => {
117
+ if (prefs.chunks) {
118
+ prefs.chunks.push(Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]));
119
+ }
120
+ return (oldWrite as typeof res['write']).apply(res, args);
121
+ }) as typeof res['write'];
122
+ res.end = ((
123
+ ...args: Parameters<typeof res['end']>
124
+ ) => {
125
+ if (args[0] && prefs.chunks) {
126
+ prefs.chunks.push(Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]));
127
+ }
128
+ return oldEnd.apply(res, args);
129
+ }) as typeof res['end'];
130
+ }
131
+
132
+ const preLog: Record<string, string | string[] | number | undefined> = {
133
+ ...getBasicInfo(req),
134
+ ref: req.headers.referer || undefined,
135
+ sid: (req as WithIdentifiedSession).session?.id,
136
+ c: req.headers.correlationid || undefined,
137
+ };
138
+ service.getLogFields?.(req as RequestWithApp<SLocals>, preLog);
139
+ logger.info(preLog, 'pre');
140
+
141
+ const logWriter = () => finishLog(app, undefined, req, res);
142
+ res.on('finish', logWriter);
143
+ next();
144
+ };
145
+ }
146
+
147
+ export function errorHandlerMiddleware<SLocals extends ServiceLocals = ServiceLocals>(
148
+ app: ServiceExpress<SLocals>,
149
+ unnest?: boolean,
150
+ returnError?: boolean,
151
+ ) {
152
+ const gbErrorHandler: ErrorRequestHandler = (error, req, res, next) => {
153
+ let loggable: Partial<ServiceError> = error;
154
+ const body = error.response?.body || error.body;
155
+ if (unnest && body?.domain && body?.code && body?.message) {
156
+ loggable = {
157
+ status: error.status,
158
+ message: body.message,
159
+ domain: body.domain,
160
+ code: body.code,
161
+ display_message: body.display_message,
162
+ };
163
+ }
164
+ // Set the status to error, even if we aren't going to render the error.
165
+ res.status(loggable.status || 500);
166
+ if (returnError) {
167
+ finishLog(app, error, req, res);
168
+ const prefs = (res.locals as WithLogPrefs)[LOG_PREFS];
169
+ prefs.logged = true;
170
+ res.json({
171
+ code: loggable.code,
172
+ message: loggable.message,
173
+ domain: loggable.domain,
174
+ display_message: loggable.display_message,
175
+ });
176
+ } else {
177
+ next(error);
178
+ }
179
+ };
180
+ return gbErrorHandler;
181
+ }
182
+
183
+ export function notFoundMiddleware() {
184
+ const gbNotFoundHandler: ServiceHandler = (req, res, next) => {
185
+ const error = new ServiceError(req.app, `Cannot ${req.method} ${req.path}`, {
186
+ status: 404,
187
+ code: 'NotFound',
188
+ domain: 'http',
189
+ });
190
+ next(error);
191
+ };
192
+ return gbNotFoundHandler as RequestHandler;
193
+ }