@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,5 +1,7 @@
1
- import { Clock, Context, Duration, Effect, Layer, Predicate, Ref, Schema } from "effect";
2
- import { BrowserEvent, BrowserEventBatch, encodeBrowserEventBatch, maxEventNameLength, maxEventsPerBatch, maxFieldKeyLength, maxFieldsPerEvent, maxFieldValueLength, } from "../BrowserEvents.js";
1
+ import { Cause, Context, Duration, Effect, Exit, Layer, Option, Schema } from "effect";
2
+ import { BrowserEvent, BrowserEventBatch, encodeBrowserEventBatch, maxEventsPerBatch, } from "../BrowserEvents.js";
3
+ import { BrowserClientEngine, normalizePositiveInteger } from "./BrowserClient.js";
4
+ export { BrowserTelemetryClientDeliveryError, BrowserTelemetryClientShutdownError, createBrowserTelemetryClient, } from "./BrowserClient.js";
3
5
  export { BrowserEvent, BrowserEventBatch, maxEventNameLength, maxEventsPerBatch, maxFieldKeyLength, maxFieldsPerEvent, maxFieldValueLength, } from "../BrowserEvents.js";
4
6
  export const defaultEventsEndpoint = "/_telemetry/events";
5
7
  export class BrowserEventDeliveryError extends Schema.TaggedError()("BrowserEventDeliveryError", {
@@ -42,62 +44,50 @@ export class BrowserEventTransport extends Context.Service()("@equipe-tech/obser
42
44
  }));
43
45
  };
44
46
  }
45
- const clampFields = (fields) => {
46
- const clamped = {};
47
- let count = 0;
48
- for (const [key, value] of Object.entries(fields)) {
49
- if (key === "" || count >= maxFieldsPerEvent) {
50
- continue;
51
- }
52
- const boundedKey = key.slice(0, maxFieldKeyLength);
53
- clamped[boundedKey] = Predicate.isString(value) ? value.slice(0, maxFieldValueLength) : value;
54
- count += 1;
55
- }
56
- return clamped;
57
- };
58
47
  const makeBrowserTelemetry = Effect.fn("makeBrowserTelemetry")(function* (options) {
59
48
  const transport = yield* BrowserEventTransport;
60
- const maxBatchSize = Math.min(options?.maxBatchSize ?? 32, maxEventsPerBatch);
61
- const maxQueueSize = options?.maxQueueSize ?? 256;
62
49
  const flushInterval = Duration.fromInputUnsafe(options?.flushInterval ?? "5 seconds");
63
- const queue = yield* Ref.make({ events: [], dropped: 0 });
64
- const emit = (name, fields) => Effect.gen(function* () {
65
- const occurredAt = yield* Clock.currentTimeMillis;
66
- const event = new BrowserEvent({
67
- id: crypto.randomUUID(),
68
- name: name.slice(0, maxEventNameLength),
69
- occurredAt,
70
- fields: clampFields(fields ?? {}),
71
- });
72
- yield* Ref.update(queue, (state) => state.events.length >= maxQueueSize
73
- ? { events: [...state.events.slice(1), event], dropped: state.dropped + 1 }
74
- : { events: [...state.events, event], dropped: state.dropped });
50
+ const engine = new BrowserClientEngine({
51
+ disabled: false,
52
+ maxBatchSize: Math.min(normalizePositiveInteger(options?.maxBatchSize, 32), maxEventsPerBatch),
53
+ maxQueueSize: normalizePositiveInteger(options?.maxQueueSize, 256),
54
+ flushIntervalMs: normalizePositiveInteger(Duration.toMillis(flushInterval), 5_000),
55
+ shutdownTimeoutMs: 2_000,
56
+ transport: (batch) => new Promise((resolve, reject) => {
57
+ Effect.runCallback(transport.send(new BrowserEventBatch({
58
+ version: 1,
59
+ events: batch.events.map((event) => new BrowserEvent(event)),
60
+ })), {
61
+ onExit: (exit) => {
62
+ if (Exit.isSuccess(exit)) {
63
+ resolve();
64
+ return;
65
+ }
66
+ const failure = Cause.findErrorOption(exit.cause);
67
+ reject(Option.isSome(failure) ? failure.value : Cause.squash(exit.cause));
68
+ },
69
+ });
70
+ }),
71
+ startTimer: false,
75
72
  });
76
- const flush = Effect.gen(function* () {
77
- while (true) {
78
- const batchEvents = yield* Ref.modify(queue, (state) => [
79
- state.events.slice(0, maxBatchSize),
80
- { events: state.events.slice(maxBatchSize), dropped: state.dropped },
81
- ]);
82
- if (batchEvents.length === 0) {
83
- return;
84
- }
85
- yield* transport.send(new BrowserEventBatch({ version: 1, events: batchEvents })).pipe(Effect.tapError(() => Ref.update(queue, (state) => {
86
- const requeued = [...batchEvents, ...state.events];
87
- return {
88
- events: requeued.slice(0, maxQueueSize),
89
- dropped: state.dropped + Math.max(0, requeued.length - maxQueueSize),
90
- };
91
- })));
92
- }
73
+ const flush = Effect.tryPromise({
74
+ try: () => engine.flush(),
75
+ catch: (cause) => cause instanceof BrowserEventDeliveryError
76
+ ? cause
77
+ : new BrowserEventDeliveryError({
78
+ code: "OBS_BROWSER_EVENTS_DELIVERY_FAILED",
79
+ message: "The browser events could not be sent. The events stay queued and the next flush retries the same batch.",
80
+ retryable: true,
81
+ cause,
82
+ }),
93
83
  });
94
84
  yield* Effect.forkScoped(flush.pipe(Effect.ignore, Effect.delay(flushInterval), Effect.forever));
95
- yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
85
+ yield* Effect.addFinalizer(() => Effect.tryPromise({ try: () => engine.dispose(), catch: () => undefined }).pipe(Effect.ignore));
96
86
  return {
97
- emit,
87
+ emit: (name, fields) => Effect.sync(() => engine.emit(name, fields ?? {})),
98
88
  flush: () => flush,
99
- pending: () => Ref.get(queue).pipe(Effect.map((state) => state.events.length)),
100
- dropped: () => Ref.get(queue).pipe(Effect.map((state) => state.dropped)),
89
+ pending: () => Effect.sync(() => engine.pending()),
90
+ dropped: () => Effect.sync(() => engine.dropped()),
101
91
  };
102
92
  });
103
93
  export class BrowserTelemetry extends Context.Service()("@equipe-tech/observability/BrowserTelemetry") {
@@ -1,7 +1,7 @@
1
1
  import { Schema } from "effect";
2
2
  import type { ManagedRuntime } from "effect";
3
3
  import { type BrowserEventIngestReceipt } from "../node/BrowserEventIngest.js";
4
- import { type RequestReference } from "./TelemetryInterceptor.js";
4
+ import type { RequestReference } from "./RequestWideEventTraceCorrelation.js";
5
5
  export declare const defaultBrowserEventsPath = "_telemetry/events";
6
6
  declare const BrowserEventsRejection_base: Schema.Class<BrowserEventsRejection, Schema.Struct<{
7
7
  readonly code: Schema.Literal<"OBS_BROWSER_EVENTS_INVALID_BATCH">;
@@ -9,6 +9,11 @@ export class BrowserEventsRejection extends Schema.Class("@equipe-tech/observabi
9
9
  correlationId: Schema.String,
10
10
  }) {
11
11
  }
12
+ class BrowserEventsWiringDefect extends Schema.TaggedError()("BrowserEventsWiringDefect", {
13
+ code: Schema.Literal("OBS_BROWSER_EVENTS_WIRING_FAILED"),
14
+ message: Schema.String,
15
+ }) {
16
+ }
12
17
  const RequestWithBody = Schema.Struct({ body: Schema.Unknown });
13
18
  const decodeRequestWithBody = Schema.decodeUnknownOption(RequestWithBody);
14
19
  const correlationId = (request) => requestSpan(request).pipe(Option.map((span) => span.traceId), Option.getOrElse(() => crypto.randomUUID()));
@@ -34,7 +39,10 @@ export const createBrowserEventsController = (runtime, options) => {
34
39
  const prototype = BrowserEventsController.prototype;
35
40
  const descriptor = Object.getOwnPropertyDescriptor(prototype, "events");
36
41
  if (descriptor === undefined) {
37
- throw new Error("The events handler is missing on the controller prototype.");
42
+ throw new BrowserEventsWiringDefect({
43
+ code: "OBS_BROWSER_EVENTS_WIRING_FAILED",
44
+ message: "The events handler is missing on the controller prototype.",
45
+ });
38
46
  }
39
47
  Controller()(BrowserEventsController);
40
48
  Post(options?.path ?? defaultBrowserEventsPath)(prototype, "events", descriptor);
@@ -0,0 +1,23 @@
1
+ import { Option } from "effect";
2
+ export type ProxyPolicy = "direct" | "framework";
3
+ export type TelemetryRoutePolicyOptions = {
4
+ readonly healthRouteTemplates?: ReadonlyArray<string> | undefined;
5
+ readonly proxyPolicy?: ProxyPolicy | undefined;
6
+ };
7
+ export type HttpServerRequest = {
8
+ readonly method: string;
9
+ readonly methodOriginal: Option.Option<string>;
10
+ readonly route: Option.Option<string>;
11
+ readonly spanName: string;
12
+ readonly urlPath: Option.Option<string>;
13
+ readonly urlScheme: Option.Option<string>;
14
+ readonly clientAddress: Option.Option<string>;
15
+ readonly networkPeerAddress: Option.Option<string>;
16
+ readonly networkPeerPort: Option.Option<number>;
17
+ readonly serverAddress: Option.Option<string>;
18
+ };
19
+ export type TelemetryRoutePolicy = {
20
+ readonly inspect: (request: WeakKey) => Option.Option<HttpServerRequest>;
21
+ };
22
+ export declare const telemetryRoutePolicy: (options?: TelemetryRoutePolicyOptions) => TelemetryRoutePolicy;
23
+ export declare const inspectHttpServerRequest: (request: WeakKey, options?: TelemetryRoutePolicyOptions) => Option.Option<HttpServerRequest>;
@@ -0,0 +1,179 @@
1
+ import { Option, Predicate, Schema } from "effect";
2
+ import { isIP } from "node:net";
3
+ const maxMethodLength = 32;
4
+ const maxRouteLength = 256;
5
+ const maxTargetLength = 2048;
6
+ const maxAddressLength = 128;
7
+ const HttpMethod = Schema.NonEmptyString.check(Schema.isMaxLength(maxMethodLength), Schema.isPattern(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/));
8
+ const RouteTemplate = Schema.NonEmptyString.check(Schema.isMaxLength(maxRouteLength), Schema.isPattern(/^\/[\x21-\x7e]*$/), Schema.makeFilter((route) => !route.includes("//") &&
9
+ !route.includes("\\") &&
10
+ !route.includes("?") &&
11
+ !route.includes("#") &&
12
+ !route.includes("@") &&
13
+ !route.includes("://"), { expected: "a bounded absolute route template without a URL authority or query" }));
14
+ const RequestTarget = Schema.NonEmptyString.check(Schema.isMaxLength(maxTargetLength));
15
+ const NetworkAddress = Schema.NonEmptyString.check(Schema.isMaxLength(maxAddressLength), Schema.makeFilter((address) => isIP(address) !== 0, { expected: "an IP address" }));
16
+ const ServerAddress = Schema.NonEmptyString.check(Schema.isMaxLength(maxAddressLength), Schema.isPattern(/^[A-Za-z0-9.:[\]_-]+$/));
17
+ const NetworkPort = Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 65_535 }));
18
+ const HttpRequestBoundary = Schema.Struct({ method: HttpMethod });
19
+ const ExpressRouteBoundary = Schema.Struct({ route: Schema.Struct({ path: RouteTemplate }) });
20
+ const FastifyRouteBoundary = Schema.Struct({
21
+ routeOptions: Schema.Struct({ url: RouteTemplate }),
22
+ });
23
+ const HttpTargetBoundary = Schema.Struct({
24
+ originalUrl: RequestTarget.pipe(Schema.optionalKey),
25
+ url: RequestTarget.pipe(Schema.optionalKey),
26
+ });
27
+ const FrameworkProtocol = Schema.Literals(["http", "https"]);
28
+ const decodeHttpRequestBoundary = Schema.decodeUnknownOption(HttpRequestBoundary);
29
+ const decodeExpressRouteBoundary = Schema.decodeUnknownOption(ExpressRouteBoundary);
30
+ const decodeFastifyRouteBoundary = Schema.decodeUnknownOption(FastifyRouteBoundary);
31
+ const decodeHttpTargetBoundary = Schema.decodeUnknownOption(HttpTargetBoundary);
32
+ const decodeNetworkAddress = Schema.decodeUnknownOption(NetworkAddress);
33
+ const decodeServerAddress = Schema.decodeUnknownOption(ServerAddress);
34
+ const decodeNetworkPort = Schema.decodeUnknownOption(NetworkPort);
35
+ const decodeEncryptedSocket = Schema.decodeUnknownOption(Schema.Boolean);
36
+ const decodeFrameworkProtocol = Schema.decodeUnknownOption(FrameworkProtocol);
37
+ const decodeRouteTemplates = Schema.decodeUnknownOption(Schema.Array(RouteTemplate));
38
+ const knownMethods = new Set([
39
+ "CONNECT",
40
+ "DELETE",
41
+ "GET",
42
+ "HEAD",
43
+ "OPTIONS",
44
+ "PATCH",
45
+ "POST",
46
+ "PUT",
47
+ "TRACE",
48
+ ]);
49
+ const defaultExcludedRoutes = ["/health", "/_telemetry/events"];
50
+ const staticSegmentPattern = /^[A-Za-z0-9._~-]+$/;
51
+ const parameterSegmentPattern = /^:[A-Za-z_][A-Za-z0-9_]*$/;
52
+ const wildcardSegmentPattern = /^(?:\*[A-Za-z_][A-Za-z0-9_]*|\{\*[A-Za-z_][A-Za-z0-9_]*\})$/;
53
+ const normalizeRoute = (route) => route.length > 1 && route.endsWith("/") ? route.slice(0, -1) : route;
54
+ const normalizedRoute = (request) => decodeExpressRouteBoundary(request).pipe(Option.map((boundary) => normalizeRoute(boundary.route.path)), Option.orElse(() => decodeFastifyRouteBoundary(request).pipe(Option.map((boundary) => normalizeRoute(boundary.routeOptions.url)))));
55
+ const normalizedMethod = (request) => decodeHttpRequestBoundary(request).pipe(Option.match({
56
+ onNone: () => ({ method: "_OTHER", original: Option.none() }),
57
+ onSome: (boundary) => {
58
+ const normalized = boundary.method.toUpperCase();
59
+ const method = knownMethods.has(normalized) ? normalized : "_OTHER";
60
+ return {
61
+ method,
62
+ original: boundary.method === method ? Option.none() : Option.some(boundary.method),
63
+ };
64
+ },
65
+ }));
66
+ const rawPath = (request) => decodeHttpTargetBoundary(request).pipe(Option.flatMap((boundary) => Option.fromNullishOr(boundary.originalUrl).pipe(Option.orElse(() => Option.fromNullishOr(boundary.url)))), Option.filter((target) => target.startsWith("/") &&
67
+ !target.startsWith("//") &&
68
+ !target.includes("#") &&
69
+ !target.includes("@") &&
70
+ !target.includes("://")), Option.map((target) => target.split("?", 1)[0] ?? "/"), Option.map(normalizeRoute));
71
+ const scrubPath = (request, route) => rawPath(request).pipe(Option.flatMap((path) => {
72
+ if (route === "/") {
73
+ return path === "/" ? Option.some("/") : Option.none();
74
+ }
75
+ const routeSegments = route.split("/").slice(1);
76
+ const pathSegments = path.split("/").slice(1);
77
+ const scrubbed = [];
78
+ for (let index = 0; index < routeSegments.length; index++) {
79
+ const routeSegment = routeSegments[index] ?? "";
80
+ const pathSegment = pathSegments[index];
81
+ if (index === routeSegments.length - 1 &&
82
+ wildcardSegmentPattern.test(routeSegment) &&
83
+ pathSegment !== undefined) {
84
+ scrubbed.push("REDACTED");
85
+ return Option.some(`/${scrubbed.join("/")}`);
86
+ }
87
+ if (pathSegment === undefined) {
88
+ return Option.none();
89
+ }
90
+ if (parameterSegmentPattern.test(routeSegment)) {
91
+ scrubbed.push("REDACTED");
92
+ }
93
+ else if (staticSegmentPattern.test(routeSegment) && routeSegment === pathSegment) {
94
+ scrubbed.push(routeSegment);
95
+ }
96
+ else {
97
+ return Option.none();
98
+ }
99
+ }
100
+ if (pathSegments.length !== routeSegments.length) {
101
+ return Option.none();
102
+ }
103
+ return Option.some(scrubbed.length === 0 ? "/" : `/${scrubbed.join("/")}`);
104
+ }));
105
+ const directNetwork = (request) => {
106
+ if (!Predicate.hasProperty(request, "socket")) {
107
+ return {
108
+ urlScheme: Option.some("http"),
109
+ clientAddress: Option.none(),
110
+ networkPeerAddress: Option.none(),
111
+ networkPeerPort: Option.none(),
112
+ serverAddress: Option.none(),
113
+ };
114
+ }
115
+ const socket = request.socket;
116
+ const address = Predicate.hasProperty(socket, "remoteAddress")
117
+ ? decodeNetworkAddress(socket.remoteAddress)
118
+ : Option.none();
119
+ const port = Predicate.hasProperty(socket, "remotePort")
120
+ ? decodeNetworkPort(socket.remotePort)
121
+ : Option.none();
122
+ const encrypted = Predicate.hasProperty(socket, "encrypted")
123
+ ? decodeEncryptedSocket(socket.encrypted)
124
+ : Option.none();
125
+ return {
126
+ urlScheme: Option.some(Option.getOrElse(encrypted, () => false) ? "https" : "http"),
127
+ clientAddress: address,
128
+ networkPeerAddress: address,
129
+ networkPeerPort: port,
130
+ serverAddress: Option.none(),
131
+ };
132
+ };
133
+ const frameworkNetwork = (request) => {
134
+ const direct = directNetwork(request);
135
+ return {
136
+ urlScheme: Predicate.hasProperty(request, "protocol")
137
+ ? decodeFrameworkProtocol(request.protocol)
138
+ : Option.none(),
139
+ clientAddress: Predicate.hasProperty(request, "ip")
140
+ ? decodeNetworkAddress(request.ip)
141
+ : Option.none(),
142
+ networkPeerAddress: direct.networkPeerAddress,
143
+ networkPeerPort: direct.networkPeerPort,
144
+ serverAddress: Predicate.hasProperty(request, "hostname")
145
+ ? decodeServerAddress(request.hostname)
146
+ : Option.none(),
147
+ };
148
+ };
149
+ export const telemetryRoutePolicy = (options = {}) => {
150
+ const additionalExclusions = decodeRouteTemplates(options.healthRouteTemplates ?? []).pipe(Option.getOrThrowWith(() => new TypeError("Telemetry health route templates must be bounded absolute paths.")));
151
+ const exclusions = new Set([...defaultExcludedRoutes, ...additionalExclusions].map(normalizeRoute));
152
+ const proxyPolicy = options.proxyPolicy ?? "direct";
153
+ if (proxyPolicy !== "direct" && proxyPolicy !== "framework") {
154
+ throw new TypeError("Telemetry proxy policy must be direct or framework.");
155
+ }
156
+ return {
157
+ inspect: (request) => {
158
+ const route = normalizedRoute(request);
159
+ if (Option.isSome(route) && exclusions.has(route.value)) {
160
+ return Option.none();
161
+ }
162
+ const requestMethod = normalizedMethod(request);
163
+ const network = proxyPolicy === "framework" ? frameworkNetwork(request) : directNetwork(request);
164
+ const spanPrefix = requestMethod.method === "_OTHER" ? "HTTP" : requestMethod.method;
165
+ return Option.some({
166
+ method: requestMethod.method,
167
+ methodOriginal: requestMethod.original,
168
+ route,
169
+ spanName: Option.match(route, {
170
+ onNone: () => spanPrefix,
171
+ onSome: (routeTemplate) => `${spanPrefix} ${routeTemplate}`,
172
+ }),
173
+ urlPath: Option.flatMap(route, (routeTemplate) => scrubPath(request, routeTemplate)),
174
+ ...network,
175
+ });
176
+ },
177
+ };
178
+ };
179
+ export const inspectHttpServerRequest = (request, options = {}) => telemetryRoutePolicy(options).inspect(request);
@@ -0,0 +1,15 @@
1
+ import { Duration, Layer } from "effect";
2
+ import type { HttpClient } from "effect/unstable/http";
3
+ import { OtlpExporter, OtlpSerialization } from "effect/unstable/observability";
4
+ export type HttpServerOtlpTracerOptions = {
5
+ readonly url: string;
6
+ readonly resource: {
7
+ readonly serviceName: string;
8
+ readonly serviceVersion: string;
9
+ readonly attributes: {
10
+ readonly "deployment.environment.name": string;
11
+ };
12
+ };
13
+ readonly shutdownTimeout?: Duration.Input | undefined;
14
+ };
15
+ export declare const layerHttpServerOtlpTracer: (options: HttpServerOtlpTracerOptions) => Layer.Layer<OtlpExporter.Flusher, never, HttpClient.HttpClient | OtlpSerialization.OtlpSerialization>;
@@ -0,0 +1,154 @@
1
+ import { Cause, Duration, Effect, Layer, Option, Schema, Tracer } from "effect";
2
+ import { OtlpExporter, OtlpResource, OtlpSerialization } from "effect/unstable/observability";
3
+ const HttpStatusCode = Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 100, maximum: 599 }));
4
+ const decodeHttpStatusCode = Schema.decodeUnknownOption(HttpStatusCode);
5
+ const statusCodeUnset = 0;
6
+ const statusCodeOk = 1;
7
+ const statusCodeError = 2;
8
+ const spanKindCode = (kind) => {
9
+ switch (kind) {
10
+ case "internal":
11
+ return 1;
12
+ case "server":
13
+ return 2;
14
+ case "client":
15
+ return 3;
16
+ case "producer":
17
+ return 4;
18
+ case "consumer":
19
+ return 5;
20
+ }
21
+ };
22
+ class ExportingSpan extends Tracer.NativeSpan {
23
+ #exportSpan;
24
+ #ended = false;
25
+ constructor(options, exportSpan) {
26
+ super(options);
27
+ this.#exportSpan = exportSpan;
28
+ }
29
+ end(endTime, exit) {
30
+ if (this.#ended) {
31
+ return;
32
+ }
33
+ this.#ended = true;
34
+ super.end(endTime, exit);
35
+ if (this.sampled) {
36
+ this.#exportSpan(this);
37
+ }
38
+ }
39
+ }
40
+ const makeEvents = (span) => span.events.map(([name, startTime, attributes]) => ({
41
+ name,
42
+ timeUnixNano: String(startTime),
43
+ attributes: OtlpResource.entriesToAttributes(Object.entries(attributes)),
44
+ droppedAttributesCount: 0,
45
+ }));
46
+ const makeNonHttpStatus = (span, attributes, events) => {
47
+ if (span.status._tag !== "Ended") {
48
+ return { code: statusCodeUnset };
49
+ }
50
+ if (span.status.exit._tag === "Success") {
51
+ return { code: statusCodeOk };
52
+ }
53
+ if (Cause.hasInterruptsOnly(span.status.exit.cause)) {
54
+ attributes.push({
55
+ key: "span.label",
56
+ value: { stringValue: "⚠︎ Interrupted" },
57
+ }, {
58
+ key: "status.interrupted",
59
+ value: { boolValue: true },
60
+ });
61
+ return { code: statusCodeOk, message: "Interrupted" };
62
+ }
63
+ const errors = Cause.prettyErrors(span.status.exit.cause, { includeCauseInStack: true });
64
+ for (const error of errors) {
65
+ events.push({
66
+ name: "exception",
67
+ timeUnixNano: String(span.status.endTime),
68
+ droppedAttributesCount: 0,
69
+ attributes: OtlpResource.entriesToAttributes([
70
+ ["exception.type", error.name],
71
+ ["exception.message", error.message],
72
+ ["exception.stacktrace", error.stack ?? "No stack trace available"],
73
+ ]),
74
+ });
75
+ }
76
+ return errors.length === 0
77
+ ? { code: statusCodeError }
78
+ : { code: statusCodeError, message: errors[0]?.message };
79
+ };
80
+ const makeHttpStatus = (span) => {
81
+ if (span.attributes.has("error.type")) {
82
+ return { code: statusCodeError };
83
+ }
84
+ return decodeHttpStatusCode(span.attributes.get("http.response.status_code")).pipe(Option.match({
85
+ onNone: () => ({ code: statusCodeUnset }),
86
+ onSome: (status) => ({ code: status >= 500 ? statusCodeError : statusCodeUnset }),
87
+ }));
88
+ };
89
+ const makeOtlpSpan = (span) => {
90
+ if (span.status._tag !== "Ended") {
91
+ return Option.none();
92
+ }
93
+ const attributes = OtlpResource.entriesToAttributes(span.attributes.entries());
94
+ const events = makeEvents(span);
95
+ const isHttpServer = span.kind === "server" && span.attributes.has("http.request.method");
96
+ return Option.some({
97
+ traceId: span.traceId,
98
+ spanId: span.spanId,
99
+ parentSpanId: Option.getOrUndefined(Option.map(span.parent, (parent) => parent.spanId)),
100
+ name: span.name,
101
+ kind: spanKindCode(span.kind),
102
+ startTimeUnixNano: String(span.status.startTime),
103
+ endTimeUnixNano: String(span.status.endTime),
104
+ attributes,
105
+ droppedAttributesCount: 0,
106
+ events,
107
+ droppedEventsCount: 0,
108
+ status: isHttpServer ? makeHttpStatus(span) : makeNonHttpStatus(span, attributes, events),
109
+ links: span.links.map((link) => ({
110
+ traceId: link.span.traceId,
111
+ spanId: link.span.spanId,
112
+ attributes: OtlpResource.entriesToAttributes(Object.entries(link.attributes)),
113
+ droppedAttributesCount: 0,
114
+ })),
115
+ droppedLinksCount: 0,
116
+ });
117
+ };
118
+ const makeHttpServerOtlpTracer = Effect.fn("makeHttpServerOtlpTracer")(function* (options) {
119
+ const resource = yield* OtlpResource.fromConfig(options.resource);
120
+ const serialization = yield* OtlpSerialization.OtlpSerialization;
121
+ const exporter = yield* OtlpExporter.make({
122
+ label: "HttpServerOtlpTracer",
123
+ url: options.url,
124
+ headers: undefined,
125
+ exportInterval: Duration.seconds(5),
126
+ maxBatchSize: 1000,
127
+ body: (spans) => [
128
+ serialization.traces({
129
+ resourceSpans: [
130
+ {
131
+ resource,
132
+ scopeSpans: [
133
+ {
134
+ scope: { name: OtlpResource.serviceNameUnsafe(resource) },
135
+ spans,
136
+ },
137
+ ],
138
+ },
139
+ ],
140
+ }),
141
+ Effect.void,
142
+ ],
143
+ shutdownTimeout: options.shutdownTimeout ?? Duration.seconds(3),
144
+ });
145
+ return Tracer.make({
146
+ span: (spanOptions) => new ExportingSpan(spanOptions, (span) => {
147
+ const exported = makeOtlpSpan(span);
148
+ if (Option.isSome(exported)) {
149
+ exporter.push(exported.value);
150
+ }
151
+ }),
152
+ });
153
+ });
154
+ export const layerHttpServerOtlpTracer = (options) => Layer.effect(Tracer.Tracer, makeHttpServerOtlpTracer(options)).pipe(Layer.provideMerge(OtlpExporter.layerFlusher));
@@ -0,0 +1,15 @@
1
+ export type RequestReference = WeakKey;
2
+ export type ServerSpanCorrelation = {
3
+ readonly traceId: string;
4
+ readonly spanId: string;
5
+ };
6
+ export type RequestWideEventLogger = {
7
+ readonly set: (correlation: ServerSpanCorrelation) => void;
8
+ };
9
+ export type RequestWideEventLoggerResolver = (request: RequestReference) => RequestWideEventLogger | undefined;
10
+ export declare class RequestWideEventTraceCorrelation {
11
+ #private;
12
+ constructor(resolveLogger: RequestWideEventLoggerResolver);
13
+ correlate(request: RequestReference, correlation: ServerSpanCorrelation): void;
14
+ }
15
+ export declare const createRequestWideEventTraceCorrelation: (resolveLogger: RequestWideEventLoggerResolver) => RequestWideEventTraceCorrelation;
@@ -0,0 +1,13 @@
1
+ export class RequestWideEventTraceCorrelation {
2
+ #resolveLogger;
3
+ constructor(resolveLogger) {
4
+ this.#resolveLogger = resolveLogger;
5
+ }
6
+ correlate(request, correlation) {
7
+ try {
8
+ this.#resolveLogger(request)?.set(correlation);
9
+ }
10
+ catch { }
11
+ }
12
+ }
13
+ export const createRequestWideEventTraceCorrelation = (resolveLogger) => new RequestWideEventTraceCorrelation(resolveLogger);
@@ -1,12 +1,31 @@
1
1
  import type { CallHandler, ExecutionContext, NestInterceptor } from "@nestjs/common";
2
- import { Effect, Option } from "effect";
3
- import type { ManagedRuntime, Tracer } from "effect";
2
+ import { Effect, Option, Tracer } from "effect";
3
+ import type { ManagedRuntime } from "effect";
4
4
  import { Observable } from "rxjs";
5
- export type RequestReference = WeakKey;
5
+ import { type ProxyPolicy } from "./HttpRoutePolicy.js";
6
+ import type { RequestReference, RequestWideEventTraceCorrelation } from "./RequestWideEventTraceCorrelation.js";
6
7
  export declare const requestSpan: (request: RequestReference) => Option.Option<Tracer.Span>;
7
8
  export declare const withRequestSpan: (request: RequestReference) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
9
+ type ActiveRequest = {
10
+ readonly interrupt: () => void;
11
+ };
12
+ export declare class TelemetryRequestTracker {
13
+ #private;
14
+ get accepting(): boolean;
15
+ register(activeRequest: ActiveRequest): Option.Option<() => void>;
16
+ closeAdmission(): void;
17
+ waitForIdle(): Promise<void>;
18
+ interruptActive(): void;
19
+ }
20
+ export type TelemetryInterceptorOptions = {
21
+ readonly healthRouteTemplates?: ReadonlyArray<string> | undefined;
22
+ readonly proxyPolicy?: ProxyPolicy | undefined;
23
+ readonly requestTracker?: TelemetryRequestTracker | undefined;
24
+ readonly requestWideEventTraceCorrelation?: RequestWideEventTraceCorrelation | undefined;
25
+ };
8
26
  export declare class TelemetryInterceptor<RuntimeError> implements NestInterceptor {
9
27
  #private;
10
- constructor(runtime: ManagedRuntime.ManagedRuntime<never, RuntimeError>);
28
+ constructor(runtime: ManagedRuntime.ManagedRuntime<never, RuntimeError>, options?: TelemetryInterceptorOptions);
11
29
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
12
30
  }
31
+ export {};