@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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type BrowserEventFields } from "./BrowserEvents.js";
|
|
2
|
+
import type { WideEventFields } from "./WideEvent.js";
|
|
3
|
+
export declare const sensitiveFieldReplacement = "****";
|
|
4
|
+
export declare const sensitiveTextReplacement = "[REDACTED]";
|
|
5
|
+
export declare const collectorBlockedKeyPattern: string;
|
|
6
|
+
export declare const collectorBlockedValuePatterns: string[];
|
|
7
|
+
export declare const isSensitiveFieldKey: (key: string) => boolean;
|
|
8
|
+
export declare const sanitizeBrowserFields: (fields: WideEventFields) => BrowserEventFields;
|
|
9
|
+
export declare const sanitizeBrowserEventName: (name: string) => string;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Option, Predicate, Schema } from "effect";
|
|
2
|
+
import { maxEventNameLength, maxFieldKeyLength, maxFieldsPerEvent, maxFieldValueLength, } from "./BrowserEvents.js";
|
|
3
|
+
export const sensitiveFieldReplacement = "****";
|
|
4
|
+
export const sensitiveTextReplacement = "[REDACTED]";
|
|
5
|
+
const sensitiveTermSources = [
|
|
6
|
+
"authorization",
|
|
7
|
+
"proxy[._-]?authorization",
|
|
8
|
+
"cookie",
|
|
9
|
+
"set[._-]?cookie",
|
|
10
|
+
"password",
|
|
11
|
+
"passwd",
|
|
12
|
+
"secret",
|
|
13
|
+
"token",
|
|
14
|
+
"api[._-]?key",
|
|
15
|
+
"apikey",
|
|
16
|
+
"access[._-]?key",
|
|
17
|
+
"client[._-]?secret",
|
|
18
|
+
"private[._-]?key",
|
|
19
|
+
"email",
|
|
20
|
+
"phone",
|
|
21
|
+
"cpf",
|
|
22
|
+
"cnpj",
|
|
23
|
+
"document",
|
|
24
|
+
];
|
|
25
|
+
export const collectorBlockedKeyPattern = `(?i:${sensitiveTermSources.join("|")})(?:[._-]|[A-Z0-9]|$)`;
|
|
26
|
+
export const collectorBlockedValuePatterns = [
|
|
27
|
+
"(?i)Bearer[[:space:]]+[A-Za-z0-9._~+/=-]+",
|
|
28
|
+
"(?:sk|rk)[_-][A-Za-z0-9_*.-]{3,}",
|
|
29
|
+
"eyJ[A-Za-z0-9_-]+[.]eyJ[A-Za-z0-9_-]+[.][A-Za-z0-9_-]+",
|
|
30
|
+
"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+[.][A-Z]{2,}",
|
|
31
|
+
"(?s)-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----.*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
|
|
32
|
+
];
|
|
33
|
+
const maxOriginalFieldKeyLength = 2_048;
|
|
34
|
+
const maxOriginalStringLength = 16_384;
|
|
35
|
+
const maxJsonDepth = 32;
|
|
36
|
+
const maxJsonValues = 1_024;
|
|
37
|
+
const SensitiveScalar = Schema.Union([
|
|
38
|
+
Schema.String,
|
|
39
|
+
Schema.Number.check(Schema.isFinite()),
|
|
40
|
+
Schema.Boolean,
|
|
41
|
+
]);
|
|
42
|
+
const decodeSensitiveScalar = Schema.decodeUnknownOption(SensitiveScalar);
|
|
43
|
+
const decodeJsonText = Schema.decodeOption(Schema.fromJsonString(Schema.Json));
|
|
44
|
+
const decodeJsonObject = Schema.decodeUnknownOption(Schema.JsonObject);
|
|
45
|
+
const asciiCaseInsensitive = (source) => source.replace(/[A-Za-z]/g, (letter) => `[${letter.toLowerCase()}${letter.toUpperCase()}]`);
|
|
46
|
+
const sensitiveTerms = sensitiveTermSources.map(asciiCaseInsensitive).join("|");
|
|
47
|
+
const sensitiveKeyPattern = new RegExp(`(?:${sensitiveTerms})(?=[._-]|[A-Z0-9]|$)`);
|
|
48
|
+
const sensitiveTextTermPattern = new RegExp(`(?:${sensitiveTerms})(?=[._-]|[A-Z0-9]|[^A-Za-z0-9._-]|$)`);
|
|
49
|
+
const coreValuePatterns = [
|
|
50
|
+
/Bearer\s+[A-Za-z0-9._~+/=-]+/gi,
|
|
51
|
+
/(?:sk|rk)[_-][A-Za-z0-9_*.-]{3,}/g,
|
|
52
|
+
/eyJ[A-Za-z0-9_-]+[.]eyJ[A-Za-z0-9_-]+[.][A-Za-z0-9_-]+/g,
|
|
53
|
+
/[A-Z0-9._%+-]+@[A-Z0-9.-]+[.][A-Z]{2,}/gi,
|
|
54
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g,
|
|
55
|
+
];
|
|
56
|
+
const structuredAssignmentPattern = /([A-Za-z0-9_.-]+)(\s*[=:]\s*)(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^,;\s]+))/g;
|
|
57
|
+
export const isSensitiveFieldKey = (key) => sensitiveKeyPattern.test(key);
|
|
58
|
+
const replaceCoreValues = (value) => {
|
|
59
|
+
let sanitized = value;
|
|
60
|
+
for (const pattern of coreValuePatterns) {
|
|
61
|
+
pattern.lastIndex = 0;
|
|
62
|
+
sanitized = sanitized.replace(pattern, sensitiveTextReplacement);
|
|
63
|
+
}
|
|
64
|
+
return sanitized;
|
|
65
|
+
};
|
|
66
|
+
const containsCoreValue = (value) => {
|
|
67
|
+
for (const pattern of coreValuePatterns) {
|
|
68
|
+
pattern.lastIndex = 0;
|
|
69
|
+
if (pattern.test(value)) {
|
|
70
|
+
pattern.lastIndex = 0;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
};
|
|
76
|
+
const replaceStructuredAssignments = (value) => {
|
|
77
|
+
structuredAssignmentPattern.lastIndex = 0;
|
|
78
|
+
let sanitized = "";
|
|
79
|
+
let offset = 0;
|
|
80
|
+
for (const match of value.matchAll(structuredAssignmentPattern)) {
|
|
81
|
+
const index = match.index;
|
|
82
|
+
const full = match[0];
|
|
83
|
+
const key = match[1];
|
|
84
|
+
const separator = match[2];
|
|
85
|
+
if (!Predicate.isNumber(index) || !Predicate.isString(full) || !Predicate.isString(key)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
sanitized += value.slice(offset, index);
|
|
89
|
+
if (!isSensitiveFieldKey(key) || !Predicate.isString(separator)) {
|
|
90
|
+
sanitized += full;
|
|
91
|
+
offset = index + full.length;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (Predicate.isString(match[3])) {
|
|
95
|
+
sanitized += `${key}${separator}"${sensitiveTextReplacement}"`;
|
|
96
|
+
}
|
|
97
|
+
else if (Predicate.isString(match[4])) {
|
|
98
|
+
sanitized += `${key}${separator}'${sensitiveTextReplacement}'`;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
sanitized += `${key}${separator}${sensitiveTextReplacement}`;
|
|
102
|
+
}
|
|
103
|
+
offset = index + full.length;
|
|
104
|
+
}
|
|
105
|
+
return sanitized + value.slice(offset);
|
|
106
|
+
};
|
|
107
|
+
const containsStructuredAssignment = (value) => replaceStructuredAssignments(value) !== value;
|
|
108
|
+
const sanitizeJson = (source) => {
|
|
109
|
+
const root = { value: null };
|
|
110
|
+
const stack = [
|
|
111
|
+
{
|
|
112
|
+
source,
|
|
113
|
+
depth: 0,
|
|
114
|
+
sensitive: false,
|
|
115
|
+
assign: (value) => {
|
|
116
|
+
root.value = value;
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
let visited = 0;
|
|
121
|
+
while (stack.length > 0) {
|
|
122
|
+
const current = stack.pop();
|
|
123
|
+
if (!Predicate.isNotUndefined(current)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
visited += 1;
|
|
127
|
+
if (visited > maxJsonValues || current.depth > maxJsonDepth) {
|
|
128
|
+
return Option.none();
|
|
129
|
+
}
|
|
130
|
+
if (current.sensitive) {
|
|
131
|
+
current.assign(sensitiveTextReplacement);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (current.source === null ||
|
|
135
|
+
Predicate.isNumber(current.source) ||
|
|
136
|
+
Predicate.isBoolean(current.source)) {
|
|
137
|
+
current.assign(current.source);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (Predicate.isString(current.source)) {
|
|
141
|
+
current.assign(replaceCoreValues(replaceStructuredAssignments(current.source)));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(current.source)) {
|
|
145
|
+
const output = current.source.map(() => null);
|
|
146
|
+
current.assign(output);
|
|
147
|
+
for (let index = current.source.length - 1; index >= 0; index -= 1) {
|
|
148
|
+
const child = current.source[index];
|
|
149
|
+
if (!Predicate.isNotUndefined(child)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
stack.push({
|
|
153
|
+
source: child,
|
|
154
|
+
depth: current.depth + 1,
|
|
155
|
+
sensitive: false,
|
|
156
|
+
assign: (value) => {
|
|
157
|
+
output[index] = value;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const sourceObject = decodeJsonObject(current.source);
|
|
164
|
+
if (Option.isNone(sourceObject)) {
|
|
165
|
+
return Option.none();
|
|
166
|
+
}
|
|
167
|
+
const output = Object.create(null);
|
|
168
|
+
const outputKeys = new Set();
|
|
169
|
+
current.assign(output);
|
|
170
|
+
const keys = Object.keys(sourceObject.value);
|
|
171
|
+
for (const key of keys) {
|
|
172
|
+
if (key.length > maxOriginalFieldKeyLength ||
|
|
173
|
+
containsCoreValue(key) ||
|
|
174
|
+
containsStructuredAssignment(key)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
output[key] = null;
|
|
178
|
+
outputKeys.add(key);
|
|
179
|
+
}
|
|
180
|
+
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
181
|
+
const key = keys[index];
|
|
182
|
+
if (!Predicate.isString(key) || !outputKeys.has(key)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const child = sourceObject.value[key];
|
|
186
|
+
if (!Predicate.isNotUndefined(child)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
stack.push({
|
|
190
|
+
source: child,
|
|
191
|
+
depth: current.depth + 1,
|
|
192
|
+
sensitive: isSensitiveFieldKey(key),
|
|
193
|
+
assign: (value) => {
|
|
194
|
+
output[key] = value;
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return Option.some(JSON.stringify(root.value));
|
|
200
|
+
};
|
|
201
|
+
const sanitizeString = (value, outputLimit) => {
|
|
202
|
+
if (value.length > maxOriginalStringLength) {
|
|
203
|
+
return sensitiveTextReplacement;
|
|
204
|
+
}
|
|
205
|
+
const trimmed = value.trimStart();
|
|
206
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
207
|
+
const parsed = decodeJsonText(value);
|
|
208
|
+
if (Option.isSome(parsed)) {
|
|
209
|
+
const sanitizedJson = Option.getOrElse(sanitizeJson(parsed.value), () => sensitiveTextReplacement);
|
|
210
|
+
return sanitizedJson.length <= outputLimit ? sanitizedJson : sensitiveTextReplacement;
|
|
211
|
+
}
|
|
212
|
+
if (sensitiveTextTermPattern.test(value)) {
|
|
213
|
+
return sensitiveTextReplacement;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return replaceCoreValues(replaceStructuredAssignments(value));
|
|
217
|
+
};
|
|
218
|
+
const shouldDropKey = (key) => key.length > maxOriginalFieldKeyLength ||
|
|
219
|
+
containsCoreValue(key) ||
|
|
220
|
+
containsStructuredAssignment(key);
|
|
221
|
+
export const sanitizeBrowserFields = (fields) => {
|
|
222
|
+
const sanitized = [];
|
|
223
|
+
const boundedKeys = new Set();
|
|
224
|
+
for (const [key, runtimeValue] of Object.entries(fields)) {
|
|
225
|
+
const decoded = decodeSensitiveScalar(runtimeValue);
|
|
226
|
+
if (Option.isNone(decoded) || key === "" || shouldDropKey(key)) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const boundedKey = key.slice(0, maxFieldKeyLength);
|
|
230
|
+
if (boundedKeys.has(boundedKey)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const value = isSensitiveFieldKey(key)
|
|
234
|
+
? sensitiveFieldReplacement
|
|
235
|
+
: Predicate.isString(decoded.value)
|
|
236
|
+
? sanitizeString(decoded.value, maxFieldValueLength).slice(0, maxFieldValueLength)
|
|
237
|
+
: decoded.value;
|
|
238
|
+
sanitized.push([boundedKey, value]);
|
|
239
|
+
boundedKeys.add(boundedKey);
|
|
240
|
+
if (boundedKeys.size >= maxFieldsPerEvent) {
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return Object.fromEntries(sanitized);
|
|
245
|
+
};
|
|
246
|
+
export const sanitizeBrowserEventName = (name) => sanitizeString(name, maxEventNameLength).slice(0, maxEventNameLength);
|
package/dist/Telemetry.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { Layer } from "effect";
|
|
1
|
+
import { Duration, Layer } from "effect";
|
|
2
2
|
import { type HttpClient } from "effect/unstable/http";
|
|
3
|
+
import { OtlpExporter } from "effect/unstable/observability";
|
|
3
4
|
import type { EnvironmentVariables, InvalidTelemetryEnvironment } from "./TelemetryConfig.js";
|
|
4
5
|
import { type TelemetryConfig } from "./TelemetryConfig.js";
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export type OtlpLayerOptions = {
|
|
7
|
+
readonly shutdownTimeout?: Duration.Input | undefined;
|
|
8
|
+
};
|
|
9
|
+
export declare const layerOtlp: (config: TelemetryConfig, options?: OtlpLayerOptions) => Layer.Layer<OtlpExporter.Flusher, never, HttpClient.HttpClient>;
|
|
10
|
+
export declare const layer: (config: TelemetryConfig, options?: OtlpLayerOptions) => Layer.Layer<OtlpExporter.Flusher>;
|
|
11
|
+
export declare const layerFromEnv: (env: EnvironmentVariables, options?: OtlpLayerOptions) => Layer.Layer<OtlpExporter.Flusher, InvalidTelemetryEnvironment>;
|
package/dist/Telemetry.js
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
import { Effect, Layer } from "effect";
|
|
2
|
-
import { FetchHttpClient } from "effect/unstable/http";
|
|
3
|
-
import {
|
|
1
|
+
import { Duration, Effect, Layer } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
3
|
+
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability";
|
|
4
4
|
import { telemetryConfigFromEnv } from "./TelemetryConfig.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { layerMetricsRuntime } from "./MetricsRuntime.js";
|
|
6
|
+
import { layerHttpServerOtlpTracer } from "./nestjs/HttpServerOtlpTracer.js";
|
|
7
|
+
export const layerOtlp = (config, options = {}) => {
|
|
8
|
+
const base = HttpClientRequest.get(config.otlpEndpoint.toString());
|
|
9
|
+
const url = (path) => HttpClientRequest.appendUrl(base, path).url;
|
|
10
|
+
const resource = {
|
|
8
11
|
serviceName: config.serviceName,
|
|
9
12
|
serviceVersion: config.serviceVersion,
|
|
10
13
|
attributes: {
|
|
11
14
|
"deployment.environment.name": config.environment,
|
|
12
15
|
},
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
};
|
|
17
|
+
const metrics = layerMetricsRuntime(config, {
|
|
18
|
+
shutdownTimeoutMilliseconds: Duration.toMillis(options.shutdownTimeout ?? "3 seconds"),
|
|
19
|
+
});
|
|
20
|
+
return Layer.mergeAll(OtlpLogger.layer({
|
|
21
|
+
url: url("/v1/logs"),
|
|
22
|
+
resource,
|
|
23
|
+
shutdownTimeout: options.shutdownTimeout,
|
|
24
|
+
}), metrics, layerHttpServerOtlpTracer({
|
|
25
|
+
url: url("/v1/traces"),
|
|
26
|
+
resource,
|
|
27
|
+
shutdownTimeout: options.shutdownTimeout,
|
|
28
|
+
})).pipe(Layer.provide(OtlpSerialization.layerJson));
|
|
29
|
+
};
|
|
30
|
+
export const layer = (config, options = {}) => layerOtlp(config, options).pipe(Layer.provide(FetchHttpClient.layer));
|
|
31
|
+
export const layerFromEnv = (env, options = {}) => Layer.unwrap(Effect.map(telemetryConfigFromEnv(env), (config) => layer(config, options)));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
|
+
export declare const OtlpEndpoint: Schema.URLFromString;
|
|
2
3
|
declare const TelemetryConfig_base: Schema.Class<TelemetryConfig, Schema.Struct<{
|
|
3
4
|
readonly serviceName: Schema.NonEmptyString;
|
|
4
5
|
readonly serviceVersion: Schema.NonEmptyString;
|
package/dist/TelemetryConfig.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
|
-
const OtlpEndpoint = Schema.URLFromString.check(Schema.makeFilter((url) => (url.protocol === "http:" || url.protocol === "https:") &&
|
|
2
|
+
export const OtlpEndpoint = Schema.URLFromString.check(Schema.makeFilter((url) => (url.protocol === "http:" || url.protocol === "https:") &&
|
|
3
3
|
url.username === "" &&
|
|
4
4
|
url.password === "", { expected: "an HTTP or HTTPS URL without credentials" }));
|
|
5
5
|
export class TelemetryConfig extends Schema.Class("@equipe-tech/observability/TelemetryConfig")({
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type BrowserTelemetryClientFields = {
|
|
2
|
+
readonly [field: string]: string | number | boolean;
|
|
3
|
+
};
|
|
4
|
+
export type BrowserTelemetryClientEvent = {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly occurredAt: number;
|
|
8
|
+
readonly fields: BrowserTelemetryClientFields;
|
|
9
|
+
};
|
|
10
|
+
export type BrowserTelemetryClientBatch = {
|
|
11
|
+
readonly version: 1;
|
|
12
|
+
readonly events: ReadonlyArray<BrowserTelemetryClientEvent>;
|
|
13
|
+
};
|
|
14
|
+
export type BrowserTelemetryClientTransport = (batch: BrowserTelemetryClientBatch, signal: AbortSignal) => Promise<void>;
|
|
15
|
+
export type BrowserTelemetryClientConfig = {
|
|
16
|
+
readonly disabled?: boolean;
|
|
17
|
+
readonly endpoint?: string;
|
|
18
|
+
readonly maxBatchSize?: number;
|
|
19
|
+
readonly maxQueueSize?: number;
|
|
20
|
+
readonly flushIntervalMs?: number;
|
|
21
|
+
readonly shutdownTimeoutMs?: number;
|
|
22
|
+
readonly transport?: BrowserTelemetryClientTransport;
|
|
23
|
+
};
|
|
24
|
+
export type BrowserTelemetryClient = {
|
|
25
|
+
emit(name: string, fields?: BrowserTelemetryClientFields): void;
|
|
26
|
+
flush(): Promise<void>;
|
|
27
|
+
pending(): number;
|
|
28
|
+
dispose(): Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
export declare class BrowserTelemetryClientDeliveryError extends Error {
|
|
31
|
+
readonly retryable: boolean;
|
|
32
|
+
readonly code = "OBS_BROWSER_EVENTS_DELIVERY_FAILED";
|
|
33
|
+
constructor(message: string, retryable: boolean, options: {
|
|
34
|
+
readonly cause: unknown;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export declare class BrowserTelemetryClientShutdownError extends Error {
|
|
38
|
+
readonly timeoutMs: number;
|
|
39
|
+
readonly code = "OBS_BROWSER_EVENTS_SHUTDOWN_TIMEOUT";
|
|
40
|
+
readonly retryable = true;
|
|
41
|
+
constructor(timeoutMs: number);
|
|
42
|
+
}
|
|
43
|
+
export declare const normalizePositiveInteger: (value: number | undefined, fallback: number) => number;
|
|
44
|
+
type BrowserClientEngineOptions = {
|
|
45
|
+
readonly disabled: boolean;
|
|
46
|
+
readonly maxBatchSize: number;
|
|
47
|
+
readonly maxQueueSize: number;
|
|
48
|
+
readonly flushIntervalMs: number;
|
|
49
|
+
readonly shutdownTimeoutMs: number;
|
|
50
|
+
readonly transport: BrowserTelemetryClientTransport;
|
|
51
|
+
readonly startTimer: boolean;
|
|
52
|
+
};
|
|
53
|
+
export declare class BrowserClientEngine implements BrowserTelemetryClient {
|
|
54
|
+
private readonly options;
|
|
55
|
+
private events;
|
|
56
|
+
private activeFlush;
|
|
57
|
+
private activeDelivery;
|
|
58
|
+
private readonly activeControllers;
|
|
59
|
+
private disposal;
|
|
60
|
+
private disposed;
|
|
61
|
+
private timer;
|
|
62
|
+
private droppedEvents;
|
|
63
|
+
constructor(options: BrowserClientEngineOptions);
|
|
64
|
+
emit(name: string, fields?: BrowserTelemetryClientFields): void;
|
|
65
|
+
flush(): Promise<void>;
|
|
66
|
+
pending(): number;
|
|
67
|
+
dropped(): number;
|
|
68
|
+
dispose(): Promise<void>;
|
|
69
|
+
private shutdown;
|
|
70
|
+
private abandonActiveDelivery;
|
|
71
|
+
private flushQueued;
|
|
72
|
+
}
|
|
73
|
+
export declare const createBrowserTelemetryClient: (config?: BrowserTelemetryClientConfig) => BrowserTelemetryClient;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { sanitizeBrowserEventName, sanitizeBrowserFields } from "../RedactionPolicy.js";
|
|
2
|
+
export class BrowserTelemetryClientDeliveryError extends Error {
|
|
3
|
+
retryable;
|
|
4
|
+
code = "OBS_BROWSER_EVENTS_DELIVERY_FAILED";
|
|
5
|
+
constructor(message, retryable, options) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
this.retryable = retryable;
|
|
8
|
+
this.name = "BrowserTelemetryClientDeliveryError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class BrowserTelemetryClientShutdownError extends Error {
|
|
12
|
+
timeoutMs;
|
|
13
|
+
code = "OBS_BROWSER_EVENTS_SHUTDOWN_TIMEOUT";
|
|
14
|
+
retryable = true;
|
|
15
|
+
constructor(timeoutMs) {
|
|
16
|
+
super(`Browser telemetry shutdown exceeded ${timeoutMs} milliseconds. The client aborted active delivery and retained its sanitized batch.`);
|
|
17
|
+
this.timeoutMs = timeoutMs;
|
|
18
|
+
this.name = "BrowserTelemetryClientShutdownError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const defaultEndpoint = "/_telemetry/events";
|
|
22
|
+
const defaultMaxBatchSize = 32;
|
|
23
|
+
const defaultMaxQueueSize = 256;
|
|
24
|
+
const defaultFlushIntervalMs = 5_000;
|
|
25
|
+
const defaultShutdownTimeoutMs = 2_000;
|
|
26
|
+
const maxBatchSizeLimit = 64;
|
|
27
|
+
const fallbackEventName = "browser.event";
|
|
28
|
+
export const normalizePositiveInteger = (value, fallback) => value === undefined || !Number.isSafeInteger(value) || value <= 0 ? fallback : value;
|
|
29
|
+
const positiveInteger = normalizePositiveInteger;
|
|
30
|
+
const fetchTransport = (endpoint) => async (batch, signal) => {
|
|
31
|
+
let response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(endpoint, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "content-type": "application/json" },
|
|
36
|
+
body: JSON.stringify(batch),
|
|
37
|
+
keepalive: true,
|
|
38
|
+
signal,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
throw new BrowserTelemetryClientDeliveryError("The browser events could not be sent. The events stay queued and the next flush retries the same batch.", true, { cause });
|
|
43
|
+
}
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new BrowserTelemetryClientDeliveryError(`The telemetry endpoint rejected the batch with status ${response.status}. Check the /_telemetry/events route of the project API.`, response.status === 429 || response.status >= 500, { cause: response.status });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
export class BrowserClientEngine {
|
|
49
|
+
options;
|
|
50
|
+
events = [];
|
|
51
|
+
activeFlush;
|
|
52
|
+
activeDelivery;
|
|
53
|
+
activeControllers = new Set();
|
|
54
|
+
disposal;
|
|
55
|
+
disposed = false;
|
|
56
|
+
timer;
|
|
57
|
+
droppedEvents = 0;
|
|
58
|
+
constructor(options) {
|
|
59
|
+
this.options = options;
|
|
60
|
+
if (!options.disabled && options.startTimer) {
|
|
61
|
+
this.timer = setInterval(() => {
|
|
62
|
+
this.flush().catch(() => undefined);
|
|
63
|
+
}, options.flushIntervalMs);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
emit(name, fields = {}) {
|
|
67
|
+
if (this.options.disabled || this.disposed)
|
|
68
|
+
return;
|
|
69
|
+
const sanitizedName = sanitizeBrowserEventName(name);
|
|
70
|
+
const event = {
|
|
71
|
+
id: crypto.randomUUID(),
|
|
72
|
+
name: sanitizedName.length === 0 ? fallbackEventName : sanitizedName,
|
|
73
|
+
occurredAt: Date.now(),
|
|
74
|
+
fields: sanitizeBrowserFields(fields),
|
|
75
|
+
};
|
|
76
|
+
if (this.events.length >= this.options.maxQueueSize) {
|
|
77
|
+
this.events.shift();
|
|
78
|
+
this.droppedEvents += 1;
|
|
79
|
+
}
|
|
80
|
+
this.events.push(event);
|
|
81
|
+
}
|
|
82
|
+
flush() {
|
|
83
|
+
if (this.options.disabled || this.disposed)
|
|
84
|
+
return Promise.resolve();
|
|
85
|
+
return this.flushQueued();
|
|
86
|
+
}
|
|
87
|
+
pending() {
|
|
88
|
+
return this.options.disabled ? 0 : this.events.length;
|
|
89
|
+
}
|
|
90
|
+
dropped() {
|
|
91
|
+
return this.droppedEvents;
|
|
92
|
+
}
|
|
93
|
+
dispose() {
|
|
94
|
+
if (this.disposal !== undefined)
|
|
95
|
+
return this.disposal;
|
|
96
|
+
this.disposed = true;
|
|
97
|
+
if (this.timer !== undefined) {
|
|
98
|
+
clearInterval(this.timer);
|
|
99
|
+
this.timer = undefined;
|
|
100
|
+
}
|
|
101
|
+
this.disposal = this.options.disabled ? Promise.resolve() : this.shutdown();
|
|
102
|
+
return this.disposal;
|
|
103
|
+
}
|
|
104
|
+
async shutdown() {
|
|
105
|
+
let deadlineTimer;
|
|
106
|
+
const deadline = new Promise((_, reject) => {
|
|
107
|
+
deadlineTimer = setTimeout(() => {
|
|
108
|
+
for (const controller of this.activeControllers)
|
|
109
|
+
controller.abort();
|
|
110
|
+
this.abandonActiveDelivery();
|
|
111
|
+
reject(new BrowserTelemetryClientShutdownError(this.options.shutdownTimeoutMs));
|
|
112
|
+
}, this.options.shutdownTimeoutMs);
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
if (this.activeFlush !== undefined) {
|
|
116
|
+
try {
|
|
117
|
+
await Promise.race([this.activeFlush, deadline]);
|
|
118
|
+
}
|
|
119
|
+
catch (cause) {
|
|
120
|
+
if (cause instanceof BrowserTelemetryClientShutdownError)
|
|
121
|
+
throw cause;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await Promise.race([this.flushQueued(true), deadline]);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
if (deadlineTimer !== undefined)
|
|
128
|
+
clearTimeout(deadlineTimer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
abandonActiveDelivery() {
|
|
132
|
+
if (this.activeDelivery === undefined || this.activeDelivery.abandoned)
|
|
133
|
+
return;
|
|
134
|
+
this.activeDelivery.abandoned = true;
|
|
135
|
+
const requeued = [...this.activeDelivery.events, ...this.events];
|
|
136
|
+
this.events = requeued.slice(0, this.options.maxQueueSize);
|
|
137
|
+
this.droppedEvents += Math.max(0, requeued.length - this.options.maxQueueSize);
|
|
138
|
+
this.activeDelivery = undefined;
|
|
139
|
+
this.activeControllers.clear();
|
|
140
|
+
this.activeFlush = undefined;
|
|
141
|
+
}
|
|
142
|
+
flushQueued(allowDisposed = false) {
|
|
143
|
+
if (this.activeFlush !== undefined)
|
|
144
|
+
return this.activeFlush;
|
|
145
|
+
const run = async () => {
|
|
146
|
+
while (this.events.length > 0 && (!this.disposed || allowDisposed)) {
|
|
147
|
+
const batchEvents = this.events.splice(0, this.options.maxBatchSize);
|
|
148
|
+
const delivery = { events: batchEvents, abandoned: false };
|
|
149
|
+
const controller = new AbortController();
|
|
150
|
+
this.activeDelivery = delivery;
|
|
151
|
+
this.activeControllers.add(controller);
|
|
152
|
+
try {
|
|
153
|
+
await this.options.transport({ version: 1, events: batchEvents }, controller.signal);
|
|
154
|
+
if (delivery.abandoned)
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
catch (cause) {
|
|
158
|
+
if (delivery.abandoned)
|
|
159
|
+
return;
|
|
160
|
+
const requeued = [...batchEvents, ...this.events];
|
|
161
|
+
this.events = requeued.slice(0, this.options.maxQueueSize);
|
|
162
|
+
this.droppedEvents += Math.max(0, requeued.length - this.options.maxQueueSize);
|
|
163
|
+
throw cause;
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
this.activeControllers.delete(controller);
|
|
167
|
+
if (this.activeDelivery === delivery)
|
|
168
|
+
this.activeDelivery = undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
this.activeFlush = run().finally(() => {
|
|
173
|
+
this.activeFlush = undefined;
|
|
174
|
+
});
|
|
175
|
+
return this.activeFlush;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export const createBrowserTelemetryClient = (config = {}) => {
|
|
179
|
+
const maxBatchSize = Math.min(positiveInteger(config.maxBatchSize, defaultMaxBatchSize), maxBatchSizeLimit);
|
|
180
|
+
return new BrowserClientEngine({
|
|
181
|
+
disabled: config.disabled ?? false,
|
|
182
|
+
maxBatchSize,
|
|
183
|
+
maxQueueSize: positiveInteger(config.maxQueueSize, defaultMaxQueueSize),
|
|
184
|
+
flushIntervalMs: positiveInteger(config.flushIntervalMs, defaultFlushIntervalMs),
|
|
185
|
+
shutdownTimeoutMs: positiveInteger(config.shutdownTimeoutMs, defaultShutdownTimeoutMs),
|
|
186
|
+
transport: config.transport ?? fetchTransport(config.endpoint ?? defaultEndpoint),
|
|
187
|
+
startTimer: true,
|
|
188
|
+
});
|
|
189
|
+
};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { BrowserTelemetryClientDeliveryError, BrowserTelemetryClientShutdownError, createBrowserTelemetryClient, } from "./BrowserClient.js";
|
|
2
|
+
export type { BrowserTelemetryClient, BrowserTelemetryClientBatch, BrowserTelemetryClientConfig, BrowserTelemetryClientEvent, BrowserTelemetryClientFields, BrowserTelemetryClientTransport, } from "./BrowserClient.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BrowserTelemetryClientDeliveryError, BrowserTelemetryClientShutdownError, createBrowserTelemetryClient, } from "./BrowserClient.js";
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { Context, Duration, Effect, Layer, Schema } from "effect";
|
|
1
|
+
import { Cause, Context, Duration, Effect, Layer, Schema } from "effect";
|
|
2
2
|
import { BrowserEventBatch } from "../BrowserEvents.js";
|
|
3
3
|
import type { WideEventFields } from "../WideEvent.js";
|
|
4
|
+
export { BrowserTelemetryClientDeliveryError, BrowserTelemetryClientShutdownError, createBrowserTelemetryClient, } from "./BrowserClient.js";
|
|
5
|
+
export type { BrowserTelemetryClient, BrowserTelemetryClientBatch, BrowserTelemetryClientConfig, BrowserTelemetryClientEvent, BrowserTelemetryClientFields, BrowserTelemetryClientTransport, } from "./BrowserClient.js";
|
|
4
6
|
export { BrowserEvent, BrowserEventBatch, maxEventNameLength, maxEventsPerBatch, maxFieldKeyLength, maxFieldsPerEvent, maxFieldValueLength, } from "../BrowserEvents.js";
|
|
5
7
|
export declare const defaultEventsEndpoint = "/_telemetry/events";
|
|
6
8
|
declare const BrowserEventDeliveryError_base: Schema.Class<BrowserEventDeliveryError, Schema.TaggedStruct<"BrowserEventDeliveryError", {
|
|
@@ -8,7 +10,7 @@ declare const BrowserEventDeliveryError_base: Schema.Class<BrowserEventDeliveryE
|
|
|
8
10
|
readonly message: Schema.String;
|
|
9
11
|
readonly retryable: Schema.Boolean;
|
|
10
12
|
readonly cause: Schema.Defect;
|
|
11
|
-
}>,
|
|
13
|
+
}>, Cause.YieldableError>;
|
|
12
14
|
export declare class BrowserEventDeliveryError extends BrowserEventDeliveryError_base {
|
|
13
15
|
}
|
|
14
16
|
declare const BrowserEventTransport_base: Context.ServiceClass<BrowserEventTransport, "@equipe-tech/observability/BrowserEventTransport", {
|