@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.
- package/.eslintignore +7 -0
- package/.eslintrc.js +14 -0
- package/.github/workflows/codeql-analysis.yml +74 -0
- package/.github/workflows/nodejs.yml +23 -0
- package/.github/workflows/npmpublish.yml +35 -0
- package/.husky/pre-commit +6 -0
- package/.prettierrc.js +14 -0
- package/@types/config.d.ts +56 -0
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/SECURITY.md +12 -0
- package/__tests__/config.test.ts +31 -0
- package/__tests__/fake-serv/api/fake-serv.yaml +48 -0
- package/__tests__/fake-serv/config/config.json +15 -0
- package/__tests__/fake-serv/src/handlers/hello.ts +10 -0
- package/__tests__/fake-serv/src/index.ts +29 -0
- package/__tests__/fake-serv/src/routes/error.ts +13 -0
- package/__tests__/fake-serv/src/routes/index.ts +22 -0
- package/__tests__/fake-serv/src/routes/other/world.ts +7 -0
- package/__tests__/fake-serv.test.ts +74 -0
- package/build/bin/start-service.d.ts +2 -0
- package/build/bin/start-service.js +31 -0
- package/build/bin/start-service.js.map +1 -0
- package/build/bootstrap.d.ts +16 -0
- package/build/bootstrap.js +90 -0
- package/build/bootstrap.js.map +1 -0
- package/build/config/index.d.ts +10 -0
- package/build/config/index.js +98 -0
- package/build/config/index.js.map +1 -0
- package/build/config/schema.d.ts +48 -0
- package/build/config/schema.js +3 -0
- package/build/config/schema.js.map +1 -0
- package/build/config/shortstops.d.ts +31 -0
- package/build/config/shortstops.js +109 -0
- package/build/config/shortstops.js.map +1 -0
- package/build/config/types.d.ts +3 -0
- package/build/config/types.js +3 -0
- package/build/config/types.js.map +1 -0
- package/build/development/port-finder.d.ts +1 -0
- package/build/development/port-finder.js +41 -0
- package/build/development/port-finder.js.map +1 -0
- package/build/development/repl.d.ts +2 -0
- package/build/development/repl.js +29 -0
- package/build/development/repl.js.map +1 -0
- package/build/env.d.ts +2 -0
- package/build/env.js +19 -0
- package/build/env.js.map +1 -0
- package/build/error.d.ts +25 -0
- package/build/error.js +28 -0
- package/build/error.js.map +1 -0
- package/build/express-app/app.d.ts +6 -0
- package/build/express-app/app.js +327 -0
- package/build/express-app/app.js.map +1 -0
- package/build/express-app/index.d.ts +2 -0
- package/build/express-app/index.js +19 -0
- package/build/express-app/index.js.map +1 -0
- package/build/express-app/internal-server.d.ts +3 -0
- package/build/express-app/internal-server.js +34 -0
- package/build/express-app/internal-server.js.map +1 -0
- package/build/express-app/route-loader.d.ts +2 -0
- package/build/express-app/route-loader.js +46 -0
- package/build/express-app/route-loader.js.map +1 -0
- package/build/express-app/types.d.ts +14 -0
- package/build/express-app/types.js +3 -0
- package/build/express-app/types.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.js +25 -0
- package/build/index.js.map +1 -0
- package/build/openapi.d.ts +5 -0
- package/build/openapi.js +78 -0
- package/build/openapi.js.map +1 -0
- package/build/service-calls/index.d.ts +16 -0
- package/build/service-calls/index.js +85 -0
- package/build/service-calls/index.js.map +1 -0
- package/build/telemetry/fetchInstrumentation.d.ts +50 -0
- package/build/telemetry/fetchInstrumentation.js +144 -0
- package/build/telemetry/fetchInstrumentation.js.map +1 -0
- package/build/telemetry/index.d.ts +6 -0
- package/build/telemetry/index.js +80 -0
- package/build/telemetry/index.js.map +1 -0
- package/build/telemetry/instrumentations.d.ts +29 -0
- package/build/telemetry/instrumentations.js +47 -0
- package/build/telemetry/instrumentations.js.map +1 -0
- package/build/telemetry/requestLogger.d.ts +6 -0
- package/build/telemetry/requestLogger.js +144 -0
- package/build/telemetry/requestLogger.js.map +1 -0
- package/build/tsconfig.build.tsbuildinfo +1 -0
- package/build/types.d.ts +77 -0
- package/build/types.js +3 -0
- package/build/types.js.map +1 -0
- package/config/config.json +31 -0
- package/config/development.json +11 -0
- package/config/test.json +5 -0
- package/jest.config.js +14 -0
- package/package.json +111 -0
- package/src/bin/start-service.ts +28 -0
- package/src/bootstrap.ts +112 -0
- package/src/config/index.ts +115 -0
- package/src/config/schema.ts +66 -0
- package/src/config/shortstops.ts +118 -0
- package/src/config/types.ts +5 -0
- package/src/development/port-finder.ts +40 -0
- package/src/development/repl.ts +24 -0
- package/src/env.ts +14 -0
- package/src/error.ts +44 -0
- package/src/express-app/app.ts +399 -0
- package/src/express-app/index.ts +2 -0
- package/src/express-app/internal-server.ts +31 -0
- package/src/express-app/route-loader.ts +48 -0
- package/src/express-app/types.ts +31 -0
- package/src/index.ts +8 -0
- package/src/openapi.ts +67 -0
- package/src/service-calls/index.ts +129 -0
- package/src/telemetry/fetchInstrumentation.ts +209 -0
- package/src/telemetry/index.ts +69 -0
- package/src/telemetry/instrumentations.ts +54 -0
- package/src/telemetry/requestLogger.ts +193 -0
- package/src/types.ts +139 -0
- package/tsconfig.build.json +10 -0
- 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
|
+
}
|