@equipe-tech/observability 0.1.0 → 0.2.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 (37) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +27 -0
  3. package/dist/Metrics.d.ts +74 -0
  4. package/dist/Metrics.js +20 -0
  5. package/dist/MetricsRuntime.d.ts +11 -0
  6. package/dist/MetricsRuntime.js +1286 -0
  7. package/dist/RedactionPolicy.d.ts +9 -0
  8. package/dist/RedactionPolicy.js +246 -0
  9. package/dist/Telemetry.d.ts +8 -4
  10. package/dist/Telemetry.js +25 -10
  11. package/dist/TelemetryConfig.d.ts +1 -0
  12. package/dist/TelemetryConfig.js +1 -1
  13. package/dist/browser/BrowserClient.d.ts +74 -0
  14. package/dist/browser/BrowserClient.js +189 -0
  15. package/dist/browser/client.d.ts +2 -0
  16. package/dist/browser/client.js +1 -0
  17. package/dist/browser/index.d.ts +4 -2
  18. package/dist/browser/index.js +40 -50
  19. package/dist/nestjs/BrowserEventsController.d.ts +1 -1
  20. package/dist/nestjs/BrowserEventsController.js +9 -1
  21. package/dist/nestjs/HttpRoutePolicy.d.ts +23 -0
  22. package/dist/nestjs/HttpRoutePolicy.js +179 -0
  23. package/dist/nestjs/HttpServerOtlpTracer.d.ts +15 -0
  24. package/dist/nestjs/HttpServerOtlpTracer.js +154 -0
  25. package/dist/nestjs/RequestWideEventTraceCorrelation.d.ts +15 -0
  26. package/dist/nestjs/RequestWideEventTraceCorrelation.js +13 -0
  27. package/dist/nestjs/TelemetryInterceptor.d.ts +23 -4
  28. package/dist/nestjs/TelemetryInterceptor.js +205 -39
  29. package/dist/nestjs/TelemetryModule.d.ts +53 -0
  30. package/dist/nestjs/TelemetryModule.js +428 -0
  31. package/dist/nestjs/index.d.ts +4 -1
  32. package/dist/nestjs/index.js +3 -1
  33. package/dist/node/BrowserEventIngest.d.ts +2 -2
  34. package/dist/node/BrowserEventIngest.js +3 -3
  35. package/dist/testing/index.d.ts +34 -2
  36. package/dist/testing/index.js +92 -10
  37. 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
- const HttpRequestBoundary = Schema.Struct({
4
- method: Schema.NonEmptyString,
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 decodeHttpRequestBoundary = Schema.decodeUnknownOption(HttpRequestBoundary);
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 ClientErrorBoundary = Schema.Struct({
12
- status: Schema.Number.check(Schema.isInt(), Schema.makeFilter((status) => status >= 400 && status <= 499, {
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 decodeClientErrorBoundary = Schema.decodeUnknownOption(ClientErrorBoundary);
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
- const method = decodeHttpRequestBoundary(request).pipe(Option.map((boundary) => boundary.method.toUpperCase()), Option.getOrElse(() => "UNKNOWN"));
47
- const controller = context.getClass().name;
48
- const handler = context.getHandler().name;
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: `${method} ${controller}.${handler}`,
51
- parent: Option.none(),
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: true,
57
- sampled: true,
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
- span.attribute("nestjs.controller", controller);
61
- span.attribute("nestjs.handler", handler);
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 settled = false;
65
- const finish = (exit, statusOverride) => {
66
- if (settled) {
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
- settled = true;
70
- const status = statusOverride.pipe(Option.orElse(() => decodeHttpResponseBoundary(httpContext.getResponse()).pipe(Option.map((boundary) => boundary.statusCode))));
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
- const clientError = decodeClientErrorBoundary(cause);
80
- if (Option.isSome(clientError)) {
81
- finish(Exit.succeed(undefined), Option.some(clientError.value.status));
82
- }
83
- else {
84
- finish(Exit.die(cause), Option.none());
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
- finish(Exit.succeed(undefined), Option.none());
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 (!settled) {
95
- span.attribute("http.request.cancelled", true);
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
+ }