@equipe-tech/observability 0.1.0 → 0.2.1
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/LICENSE +202 -0
- package/README.md +27 -0
- package/dist/Metrics.d.ts +74 -0
- package/dist/Metrics.js +20 -0
- package/dist/MetricsRuntime.d.ts +11 -0
- package/dist/MetricsRuntime.js +1286 -0
- package/dist/RedactionPolicy.d.ts +9 -0
- package/dist/RedactionPolicy.js +246 -0
- package/dist/Telemetry.d.ts +8 -4
- package/dist/Telemetry.js +25 -10
- package/dist/TelemetryConfig.d.ts +1 -0
- package/dist/TelemetryConfig.js +1 -1
- package/dist/browser/BrowserClient.d.ts +74 -0
- package/dist/browser/BrowserClient.js +189 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +4 -2
- package/dist/browser/index.js +40 -50
- package/dist/nestjs/BrowserEventsController.d.ts +1 -1
- package/dist/nestjs/BrowserEventsController.js +9 -1
- package/dist/nestjs/HttpRoutePolicy.d.ts +23 -0
- package/dist/nestjs/HttpRoutePolicy.js +179 -0
- package/dist/nestjs/HttpServerOtlpTracer.d.ts +15 -0
- package/dist/nestjs/HttpServerOtlpTracer.js +154 -0
- package/dist/nestjs/RequestWideEventTraceCorrelation.d.ts +15 -0
- package/dist/nestjs/RequestWideEventTraceCorrelation.js +13 -0
- package/dist/nestjs/TelemetryInterceptor.d.ts +23 -4
- package/dist/nestjs/TelemetryInterceptor.js +205 -39
- package/dist/nestjs/TelemetryModule.d.ts +53 -0
- package/dist/nestjs/TelemetryModule.js +428 -0
- package/dist/nestjs/index.d.ts +4 -1
- package/dist/nestjs/index.js +3 -1
- package/dist/node/BrowserEventIngest.d.ts +2 -2
- package/dist/node/BrowserEventIngest.js +3 -3
- package/dist/testing/index.d.ts +34 -2
- package/dist/testing/index.js +92 -10
- package/package.json +23 -1
|
@@ -1,34 +1,87 @@
|
|
|
1
|
-
import { Context, Effect, Exit, Option, Schema } from "effect";
|
|
1
|
+
import { Context, Effect, Exit, Option, Predicate, Schema, Tracer } from "effect";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
2
3
|
import { Observable } from "rxjs";
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
import { telemetryRoutePolicy, } from "./HttpRoutePolicy.js";
|
|
5
|
+
const TraceparentRequest = Schema.Struct({
|
|
6
|
+
headers: Schema.Struct({
|
|
7
|
+
traceparent: Schema.String.check(Schema.isPattern(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/), Schema.makeFilter((traceparent) => traceparent.slice(3, 35) !== "00000000000000000000000000000000" &&
|
|
8
|
+
traceparent.slice(36, 52) !== "0000000000000000")),
|
|
9
|
+
}),
|
|
5
10
|
});
|
|
6
|
-
const
|
|
11
|
+
const parseTraceparentRequest = Schema.decodeUnknownOption(TraceparentRequest);
|
|
7
12
|
const HttpResponseBoundary = Schema.Struct({
|
|
8
|
-
statusCode: Schema.Number.check(Schema.isInt()),
|
|
13
|
+
statusCode: Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 100, maximum: 599 })),
|
|
9
14
|
});
|
|
10
15
|
const decodeHttpResponseBoundary = Schema.decodeUnknownOption(HttpResponseBoundary);
|
|
11
|
-
const
|
|
12
|
-
status: Schema.Number.check(Schema.isInt(), Schema.
|
|
13
|
-
expected: "an HTTP client error status",
|
|
14
|
-
})),
|
|
16
|
+
const HttpErrorBoundary = Schema.Struct({
|
|
17
|
+
status: Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(Schema.optionalKey),
|
|
15
18
|
});
|
|
16
|
-
const
|
|
19
|
+
const ErrorType = Schema.NonEmptyString.check(Schema.isMaxLength(128));
|
|
20
|
+
const decodeHttpErrorBoundary = Schema.decodeUnknownOption(HttpErrorBoundary);
|
|
21
|
+
const decodeErrorType = Schema.decodeUnknownOption(ErrorType);
|
|
22
|
+
const decodeHeadersSent = Schema.decodeUnknownOption(Schema.Boolean);
|
|
23
|
+
const decodeResponseEmitter = Schema.decodeUnknownOption(Schema.instanceOf(EventEmitter));
|
|
17
24
|
const requestSpans = new WeakMap();
|
|
18
25
|
export const requestSpan = (request) => Option.fromNullishOr(requestSpans.get(request));
|
|
19
26
|
export const withRequestSpan = (request) => (effect) => Option.match(requestSpan(request), {
|
|
20
27
|
onNone: () => effect,
|
|
21
28
|
onSome: (span) => Effect.withParentSpan(effect, span),
|
|
22
29
|
});
|
|
30
|
+
export class TelemetryRequestTracker {
|
|
31
|
+
#accepting = true;
|
|
32
|
+
#active = new Set();
|
|
33
|
+
#idleWaiters = new Set();
|
|
34
|
+
get accepting() {
|
|
35
|
+
return this.#accepting;
|
|
36
|
+
}
|
|
37
|
+
register(activeRequest) {
|
|
38
|
+
if (!this.#accepting) {
|
|
39
|
+
return Option.none();
|
|
40
|
+
}
|
|
41
|
+
this.#active.add(activeRequest);
|
|
42
|
+
return Option.some(() => {
|
|
43
|
+
if (!this.#active.delete(activeRequest) || this.#active.size !== 0) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const resolve of this.#idleWaiters) {
|
|
47
|
+
resolve();
|
|
48
|
+
}
|
|
49
|
+
this.#idleWaiters.clear();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
closeAdmission() {
|
|
53
|
+
this.#accepting = false;
|
|
54
|
+
}
|
|
55
|
+
waitForIdle() {
|
|
56
|
+
if (this.#active.size === 0) {
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
return new Promise((resolve) => this.#idleWaiters.add(resolve));
|
|
60
|
+
}
|
|
61
|
+
interruptActive() {
|
|
62
|
+
for (const activeRequest of this.#active) {
|
|
63
|
+
activeRequest.interrupt();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
23
67
|
export class TelemetryInterceptor {
|
|
24
68
|
#runtime;
|
|
69
|
+
#routePolicy;
|
|
70
|
+
#requestTracker;
|
|
71
|
+
#requestWideEventTraceCorrelation;
|
|
25
72
|
#tracer;
|
|
26
73
|
#clock;
|
|
27
|
-
constructor(runtime) {
|
|
74
|
+
constructor(runtime, options = {}) {
|
|
28
75
|
this.#runtime = runtime;
|
|
76
|
+
this.#routePolicy = telemetryRoutePolicy({
|
|
77
|
+
healthRouteTemplates: options.healthRouteTemplates,
|
|
78
|
+
proxyPolicy: options.proxyPolicy,
|
|
79
|
+
});
|
|
80
|
+
this.#requestTracker = options.requestTracker ?? new TelemetryRequestTracker();
|
|
81
|
+
this.#requestWideEventTraceCorrelation = options.requestWideEventTraceCorrelation;
|
|
29
82
|
}
|
|
30
83
|
intercept(context, next) {
|
|
31
|
-
if (context.getType() !== "http") {
|
|
84
|
+
if (context.getType() !== "http" || !this.#requestTracker.accepting) {
|
|
32
85
|
return next.handle();
|
|
33
86
|
}
|
|
34
87
|
try {
|
|
@@ -39,61 +92,174 @@ export class TelemetryInterceptor {
|
|
|
39
92
|
}
|
|
40
93
|
}
|
|
41
94
|
#instrument(context, next) {
|
|
42
|
-
const tracer = (this.#tracer ??= this.#runtime.runSync(Effect.tracer));
|
|
43
|
-
const clock = (this.#clock ??= this.#runtime.runSync(Effect.clockWith(Effect.succeed)));
|
|
44
95
|
const httpContext = context.switchToHttp();
|
|
45
96
|
const request = httpContext.getRequest();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
97
|
+
if (requestSpans.has(request)) {
|
|
98
|
+
return next.handle();
|
|
99
|
+
}
|
|
100
|
+
const requestDetails = this.#routePolicy.inspect(request);
|
|
101
|
+
if (Option.isNone(requestDetails)) {
|
|
102
|
+
return next.handle();
|
|
103
|
+
}
|
|
104
|
+
const tracer = (this.#tracer ??= this.#runtime.runSync(Effect.tracer));
|
|
105
|
+
const clock = (this.#clock ??= this.#runtime.runSync(Effect.clockWith(Effect.succeed)));
|
|
106
|
+
const traceparentRequest = httpContext.getRequest();
|
|
107
|
+
const parent = parseTraceparentRequest({ headers: traceparentRequest.headers }).pipe(Option.map(({ headers }) => {
|
|
108
|
+
const traceparent = headers.traceparent;
|
|
109
|
+
return Tracer.externalSpan({
|
|
110
|
+
traceId: traceparent.slice(3, 35),
|
|
111
|
+
spanId: traceparent.slice(36, 52),
|
|
112
|
+
sampled: Number.parseInt(traceparent.slice(53, 55), 16) % 2 === 1,
|
|
113
|
+
});
|
|
114
|
+
}));
|
|
115
|
+
const details = requestDetails.value;
|
|
49
116
|
const span = tracer.span({
|
|
50
|
-
name:
|
|
51
|
-
parent
|
|
117
|
+
name: details.spanName,
|
|
118
|
+
parent,
|
|
52
119
|
annotations: Context.empty(),
|
|
53
120
|
links: [],
|
|
54
121
|
startTime: clock.currentTimeNanosUnsafe(),
|
|
55
122
|
kind: "server",
|
|
56
|
-
root:
|
|
57
|
-
sampled:
|
|
123
|
+
root: Option.isNone(parent),
|
|
124
|
+
sampled: Option.match(parent, {
|
|
125
|
+
onNone: () => true,
|
|
126
|
+
onSome: (remoteParent) => remoteParent.sampled,
|
|
127
|
+
}),
|
|
58
128
|
});
|
|
59
|
-
span.attribute("http.request.method", method);
|
|
60
|
-
|
|
61
|
-
|
|
129
|
+
span.attribute("http.request.method", details.method);
|
|
130
|
+
if (Option.isSome(details.methodOriginal)) {
|
|
131
|
+
span.attribute("http.request.method_original", details.methodOriginal.value);
|
|
132
|
+
}
|
|
133
|
+
if (Option.isSome(details.route)) {
|
|
134
|
+
span.attribute("http.route", details.route.value);
|
|
135
|
+
}
|
|
136
|
+
if (Option.isSome(details.urlPath)) {
|
|
137
|
+
span.attribute("url.path", details.urlPath.value);
|
|
138
|
+
}
|
|
139
|
+
if (Option.isSome(details.urlScheme)) {
|
|
140
|
+
span.attribute("url.scheme", details.urlScheme.value);
|
|
141
|
+
}
|
|
142
|
+
if (Option.isSome(details.clientAddress)) {
|
|
143
|
+
span.attribute("client.address", details.clientAddress.value);
|
|
144
|
+
}
|
|
145
|
+
if (Option.isSome(details.networkPeerAddress)) {
|
|
146
|
+
span.attribute("network.peer.address", details.networkPeerAddress.value);
|
|
147
|
+
}
|
|
148
|
+
if (Option.isSome(details.networkPeerPort)) {
|
|
149
|
+
span.attribute("network.peer.port", details.networkPeerPort.value);
|
|
150
|
+
}
|
|
151
|
+
if (Option.isSome(details.serverAddress)) {
|
|
152
|
+
span.attribute("server.address", details.serverAddress.value);
|
|
153
|
+
}
|
|
62
154
|
requestSpans.set(request, span);
|
|
155
|
+
this.#requestWideEventTraceCorrelation?.correlate(request, {
|
|
156
|
+
traceId: span.traceId,
|
|
157
|
+
spanId: span.spanId,
|
|
158
|
+
});
|
|
159
|
+
const response = httpContext.getResponse();
|
|
160
|
+
const responseEmitter = decodeResponseEmitter(response);
|
|
63
161
|
return new Observable((subscriber) => {
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
162
|
+
let ended = false;
|
|
163
|
+
let responseFinished = false;
|
|
164
|
+
let observableSettled = false;
|
|
165
|
+
let pendingExit = Exit.succeed(undefined);
|
|
166
|
+
let pendingErrorType = Option.none();
|
|
167
|
+
let release = () => { };
|
|
168
|
+
const responseStatus = (requireSent = false) => {
|
|
169
|
+
if (requireSent) {
|
|
170
|
+
const headersSent = Predicate.hasProperty(response, "headersSent")
|
|
171
|
+
? decodeHeadersSent(response.headersSent)
|
|
172
|
+
: Option.none();
|
|
173
|
+
if (!Option.getOrElse(headersSent, () => false)) {
|
|
174
|
+
return Option.none();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return decodeHttpResponseBoundary(response).pipe(Option.map((boundary) => boundary.statusCode));
|
|
178
|
+
};
|
|
179
|
+
const removeResponseListeners = () => {
|
|
180
|
+
if (Option.isSome(responseEmitter)) {
|
|
181
|
+
responseEmitter.value.off("finish", onResponseFinish);
|
|
182
|
+
responseEmitter.value.off("close", onResponseClose);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const finish = (exit, status, errorType) => {
|
|
186
|
+
if (ended) {
|
|
67
187
|
return;
|
|
68
188
|
}
|
|
69
|
-
|
|
70
|
-
|
|
189
|
+
ended = true;
|
|
190
|
+
removeResponseListeners();
|
|
71
191
|
if (Option.isSome(status)) {
|
|
72
192
|
span.attribute("http.response.status_code", status.value);
|
|
73
193
|
}
|
|
194
|
+
const finalErrorType = Option.contains(errorType, "connection_closed")
|
|
195
|
+
? errorType
|
|
196
|
+
: Option.match(status, {
|
|
197
|
+
onNone: () => errorType,
|
|
198
|
+
onSome: (statusCode) => {
|
|
199
|
+
if (statusCode >= 500) {
|
|
200
|
+
return Option.some(String(statusCode));
|
|
201
|
+
}
|
|
202
|
+
if (statusCode >= 400) {
|
|
203
|
+
return Option.none();
|
|
204
|
+
}
|
|
205
|
+
return errorType;
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
if (Option.isSome(finalErrorType)) {
|
|
209
|
+
span.attribute("error.type", finalErrorType.value);
|
|
210
|
+
}
|
|
211
|
+
requestSpans.delete(request);
|
|
212
|
+
release();
|
|
74
213
|
span.end(clock.currentTimeNanosUnsafe(), exit);
|
|
75
214
|
};
|
|
215
|
+
const onResponseFinish = () => {
|
|
216
|
+
responseFinished = true;
|
|
217
|
+
finish(pendingExit, responseStatus(), pendingErrorType);
|
|
218
|
+
};
|
|
219
|
+
const onResponseClose = () => {
|
|
220
|
+
if (!responseFinished) {
|
|
221
|
+
finish(Exit.interrupt(), responseStatus(true), Option.some("connection_closed"));
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const registered = this.#requestTracker.register({
|
|
225
|
+
interrupt: () => finish(Exit.interrupt(), responseStatus(true), Option.none()),
|
|
226
|
+
});
|
|
227
|
+
if (Option.isNone(registered)) {
|
|
228
|
+
requestSpans.delete(request);
|
|
229
|
+
span.end(clock.currentTimeNanosUnsafe(), Exit.interrupt());
|
|
230
|
+
return next.handle().subscribe(subscriber);
|
|
231
|
+
}
|
|
232
|
+
release = registered.value;
|
|
233
|
+
if (Option.isSome(responseEmitter)) {
|
|
234
|
+
responseEmitter.value.on("finish", onResponseFinish);
|
|
235
|
+
responseEmitter.value.on("close", onResponseClose);
|
|
236
|
+
}
|
|
76
237
|
const subscription = next.handle().subscribe({
|
|
77
238
|
next: (value) => subscriber.next(value),
|
|
78
239
|
error: (cause) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
240
|
+
observableSettled = true;
|
|
241
|
+
const errorBoundary = decodeHttpErrorBoundary(cause);
|
|
242
|
+
pendingExit = Exit.die(cause);
|
|
243
|
+
pendingErrorType = Predicate.hasProperty(cause, "name")
|
|
244
|
+
? decodeErrorType(cause.name).pipe(Option.orElse(() => Option.some("exception")))
|
|
245
|
+
: Option.some("exception");
|
|
246
|
+
if (Option.isNone(responseEmitter)) {
|
|
247
|
+
const status = errorBoundary.pipe(Option.flatMap((boundary) => Option.fromNullishOr(boundary.status)));
|
|
248
|
+
finish(pendingExit, status, pendingErrorType);
|
|
85
249
|
}
|
|
86
250
|
subscriber.error(cause);
|
|
87
251
|
},
|
|
88
252
|
complete: () => {
|
|
89
|
-
|
|
253
|
+
observableSettled = true;
|
|
254
|
+
if (Option.isNone(responseEmitter)) {
|
|
255
|
+
finish(Exit.succeed(undefined), responseStatus(), Option.none());
|
|
256
|
+
}
|
|
90
257
|
subscriber.complete();
|
|
91
258
|
},
|
|
92
259
|
});
|
|
93
260
|
return () => {
|
|
94
|
-
if (!
|
|
95
|
-
|
|
96
|
-
finish(Exit.interrupt(), Option.none());
|
|
261
|
+
if (!observableSettled && Option.isNone(responseEmitter)) {
|
|
262
|
+
finish(Exit.interrupt(), responseStatus(), Option.none());
|
|
97
263
|
}
|
|
98
264
|
subscription.unsubscribe();
|
|
99
265
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DynamicModule, InjectionToken, ModuleMetadata, OptionalFactoryDependency } from "@nestjs/common";
|
|
2
|
+
import { type ProxyPolicy } from "./HttpRoutePolicy.js";
|
|
3
|
+
import { RequestWideEventTraceCorrelation } from "./RequestWideEventTraceCorrelation.js";
|
|
4
|
+
export type TelemetryModuleOptions = {
|
|
5
|
+
readonly enabled: false;
|
|
6
|
+
} | {
|
|
7
|
+
readonly enabled: true;
|
|
8
|
+
readonly serviceName: string;
|
|
9
|
+
readonly serviceVersion: string;
|
|
10
|
+
readonly environment: string;
|
|
11
|
+
readonly otlpEndpoint: string;
|
|
12
|
+
readonly healthRouteTemplates?: ReadonlyArray<string> | undefined;
|
|
13
|
+
readonly proxyPolicy?: ProxyPolicy | undefined;
|
|
14
|
+
readonly requestWideEventTraceCorrelation?: RequestWideEventTraceCorrelation | undefined;
|
|
15
|
+
readonly shutdownTimeoutMilliseconds?: number | undefined;
|
|
16
|
+
};
|
|
17
|
+
export interface TelemetryModuleAsyncOptions {
|
|
18
|
+
readonly imports?: ModuleMetadata["imports"] | undefined;
|
|
19
|
+
readonly inject?: Array<InjectionToken | OptionalFactoryDependency> | undefined;
|
|
20
|
+
readonly useFactory: (...dependencies: Array<never>) => TelemetryModuleOptions | Promise<TelemetryModuleOptions>;
|
|
21
|
+
}
|
|
22
|
+
export declare class InvalidTelemetryModuleOptions extends Error {
|
|
23
|
+
readonly _tag = "InvalidTelemetryModuleOptions";
|
|
24
|
+
readonly code = "OBS_TELEMETRY_INVALID_MODULE_OPTIONS";
|
|
25
|
+
readonly cause: unknown;
|
|
26
|
+
constructor(cause: unknown);
|
|
27
|
+
}
|
|
28
|
+
export declare class TelemetryStartupError extends Error {
|
|
29
|
+
readonly _tag = "TelemetryStartupError";
|
|
30
|
+
readonly code = "OBS_TELEMETRY_STARTUP_FAILED";
|
|
31
|
+
readonly cause: unknown;
|
|
32
|
+
constructor(cause: unknown);
|
|
33
|
+
}
|
|
34
|
+
export declare class TelemetryShutdownError extends Error {
|
|
35
|
+
readonly _tag = "TelemetryShutdownError";
|
|
36
|
+
readonly code = "OBS_TELEMETRY_SHUTDOWN_FAILED";
|
|
37
|
+
readonly cause: unknown;
|
|
38
|
+
constructor(cause: unknown);
|
|
39
|
+
}
|
|
40
|
+
export interface TelemetryModuleTestingOverrides {
|
|
41
|
+
readonly scopedResource?: {
|
|
42
|
+
readonly acquire: () => void | Promise<void>;
|
|
43
|
+
readonly release: () => void | Promise<void>;
|
|
44
|
+
} | undefined;
|
|
45
|
+
readonly startupProbe?: (() => void | Promise<void>) | undefined;
|
|
46
|
+
readonly beforeRequestDrain?: (() => void | Promise<void>) | undefined;
|
|
47
|
+
readonly beforeRuntimeDispose?: (() => void | Promise<void>) | undefined;
|
|
48
|
+
readonly onRuntimeDisposed?: (() => void) | undefined;
|
|
49
|
+
}
|
|
50
|
+
export declare const telemetryModuleForTesting: (options: TelemetryModuleAsyncOptions, overrides: TelemetryModuleTestingOverrides) => DynamicModule;
|
|
51
|
+
export declare class TelemetryModule {
|
|
52
|
+
static forRootAsync(options: TelemetryModuleAsyncOptions): DynamicModule;
|
|
53
|
+
}
|