@opengeni/observability 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.
@@ -0,0 +1,62 @@
1
+ type AttributeValue = string | number | boolean | null | undefined;
2
+ type Attributes = Record<string, AttributeValue>;
3
+ type ObservabilitySettings = {
4
+ serviceName: string;
5
+ environment: string;
6
+ observabilityStructuredLogs: boolean;
7
+ observabilityMetricsEnabled: boolean;
8
+ observabilityOtlpEndpoint?: string | undefined;
9
+ observabilityOtlpHeaders: string;
10
+ };
11
+ type ObservabilityOptions = {
12
+ component: string;
13
+ now?: () => number;
14
+ exporter?: (url: string, body: unknown, headers: Record<string, string>) => Promise<void>;
15
+ };
16
+ type Span = {
17
+ traceId: string;
18
+ spanId: string;
19
+ end: (input?: {
20
+ attributes?: Attributes;
21
+ error?: unknown;
22
+ }) => void;
23
+ };
24
+ declare function createObservability(settings: ObservabilitySettings, options: ObservabilityOptions): Observability;
25
+ declare class Observability {
26
+ private readonly settings;
27
+ private readonly options;
28
+ private readonly metrics;
29
+ private readonly now;
30
+ private readonly exporter;
31
+ private readonly resourceAttributes;
32
+ constructor(settings: ObservabilitySettings, options: ObservabilityOptions);
33
+ info(message: string, attributes?: Attributes): void;
34
+ warn(message: string, attributes?: Attributes): void;
35
+ error(message: string, attributes?: Attributes): void;
36
+ log(level: "debug" | "info" | "warn" | "error", message: string, attributes?: Attributes): void;
37
+ startSpan(name: string, attributes?: Attributes): Span;
38
+ recordHttpRequest(input: {
39
+ method: string;
40
+ route: string;
41
+ status: number;
42
+ durationSeconds: number;
43
+ }): void;
44
+ recordWorkerActivity(input: {
45
+ activity: string;
46
+ status: string;
47
+ durationSeconds: number;
48
+ }): void;
49
+ prometheusMetrics(): string;
50
+ private exportSpan;
51
+ }
52
+ type StartupDependencyRetryEvent = {
53
+ label: string;
54
+ attempt: number;
55
+ attempts: number;
56
+ delayMs: number;
57
+ error: unknown;
58
+ };
59
+ declare function logStartupDependencyRetry(observability: Observability, event: StartupDependencyRetryEvent): void;
60
+ declare function parseHeaders(value: string): Record<string, string>;
61
+
62
+ export { type AttributeValue, type Attributes, Observability, type ObservabilityOptions, type ObservabilitySettings, type Span, type StartupDependencyRetryEvent, createObservability, logStartupDependencyRetry, parseHeaders };
package/dist/index.js ADDED
@@ -0,0 +1,315 @@
1
+ // src/index.ts
2
+ var histogramBuckets = [5e-3, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
3
+ function createObservability(settings, options) {
4
+ return new Observability(settings, options);
5
+ }
6
+ var Observability = class {
7
+ constructor(settings, options) {
8
+ this.settings = settings;
9
+ this.options = options;
10
+ this.now = options.now ?? Date.now;
11
+ this.exporter = options.exporter ?? defaultExporter;
12
+ this.resourceAttributes = {
13
+ "service.name": settings.serviceName,
14
+ "deployment.environment": settings.environment,
15
+ "opengeni.component": options.component
16
+ };
17
+ }
18
+ settings;
19
+ options;
20
+ metrics = new MetricsRegistry();
21
+ now;
22
+ exporter;
23
+ resourceAttributes;
24
+ info(message, attributes = {}) {
25
+ this.log("info", message, attributes);
26
+ }
27
+ warn(message, attributes = {}) {
28
+ this.log("warn", message, attributes);
29
+ }
30
+ error(message, attributes = {}) {
31
+ this.log("error", message, attributes);
32
+ }
33
+ log(level, message, attributes = {}) {
34
+ if (!this.settings.observabilityStructuredLogs) {
35
+ const line = attributes.error ? `${message}: ${String(attributes.error)}` : message;
36
+ if (level === "warn") {
37
+ console.warn(line);
38
+ } else if (level === "error") {
39
+ console.error(line);
40
+ } else {
41
+ console.log(line);
42
+ }
43
+ return;
44
+ }
45
+ const record = {
46
+ timestamp: new Date(this.now()).toISOString(),
47
+ level,
48
+ message,
49
+ service: this.settings.serviceName,
50
+ environment: this.settings.environment,
51
+ component: this.options.component,
52
+ ...cleanAttributes(attributes)
53
+ };
54
+ const serialized = JSON.stringify(record);
55
+ if (level === "warn") {
56
+ console.warn(serialized);
57
+ } else if (level === "error") {
58
+ console.error(serialized);
59
+ } else {
60
+ console.log(serialized);
61
+ }
62
+ }
63
+ startSpan(name, attributes = {}) {
64
+ const traceId = randomHex(16);
65
+ const spanId = randomHex(8);
66
+ const startMs = this.now();
67
+ let ended = false;
68
+ return {
69
+ traceId,
70
+ spanId,
71
+ end: (input = {}) => {
72
+ if (ended) {
73
+ return;
74
+ }
75
+ ended = true;
76
+ const errorAttributes = input.error ? errorToAttributes(input.error) : {};
77
+ this.exportSpan({
78
+ traceId,
79
+ spanId,
80
+ name,
81
+ startMs,
82
+ endMs: this.now(),
83
+ attributes: {
84
+ ...attributes,
85
+ ...input.attributes,
86
+ ...errorAttributes
87
+ },
88
+ error: input.error
89
+ });
90
+ }
91
+ };
92
+ }
93
+ recordHttpRequest(input) {
94
+ if (!this.settings.observabilityMetricsEnabled) {
95
+ return;
96
+ }
97
+ const labels = {
98
+ method: input.method,
99
+ route: input.route,
100
+ status: String(input.status),
101
+ component: this.options.component
102
+ };
103
+ this.metrics.increment("opengeni_http_requests_total", labels);
104
+ this.metrics.observe("opengeni_http_request_duration_seconds", histogramBuckets, input.durationSeconds, {
105
+ method: input.method,
106
+ route: input.route,
107
+ component: this.options.component
108
+ });
109
+ }
110
+ recordWorkerActivity(input) {
111
+ if (!this.settings.observabilityMetricsEnabled) {
112
+ return;
113
+ }
114
+ const labels = {
115
+ activity: input.activity,
116
+ status: input.status,
117
+ component: this.options.component
118
+ };
119
+ this.metrics.increment("opengeni_worker_activity_runs_total", labels);
120
+ this.metrics.observe("opengeni_worker_activity_duration_seconds", histogramBuckets, input.durationSeconds, {
121
+ activity: input.activity,
122
+ component: this.options.component
123
+ });
124
+ }
125
+ prometheusMetrics() {
126
+ return this.metrics.toPrometheus({
127
+ service: this.settings.serviceName,
128
+ environment: this.settings.environment,
129
+ component: this.options.component
130
+ });
131
+ }
132
+ exportSpan(span) {
133
+ if (!this.settings.observabilityOtlpEndpoint) {
134
+ return;
135
+ }
136
+ const endpoint = `${this.settings.observabilityOtlpEndpoint.replace(/\/$/, "")}/v1/traces`;
137
+ const body = {
138
+ resourceSpans: [{
139
+ resource: {
140
+ attributes: otlpAttributes(this.resourceAttributes)
141
+ },
142
+ scopeSpans: [{
143
+ scope: {
144
+ name: "@opengeni/observability",
145
+ version: "0.1.0"
146
+ },
147
+ spans: [{
148
+ traceId: span.traceId,
149
+ spanId: span.spanId,
150
+ name: span.name,
151
+ kind: 1,
152
+ startTimeUnixNano: millisToNanos(span.startMs),
153
+ endTimeUnixNano: millisToNanos(span.endMs),
154
+ attributes: otlpAttributes(span.attributes),
155
+ status: span.error ? { code: 2, message: errorMessage(span.error) } : { code: 1 }
156
+ }]
157
+ }]
158
+ }]
159
+ };
160
+ void this.exporter(endpoint, body, parseHeaders(this.settings.observabilityOtlpHeaders)).catch((error) => {
161
+ this.warn("OTLP span export failed", { error: errorMessage(error), endpoint });
162
+ });
163
+ }
164
+ };
165
+ function logStartupDependencyRetry(observability, event) {
166
+ const message = event.error instanceof Error ? event.error.message : String(event.error);
167
+ observability.warn("Startup dependency connection failed; retrying", {
168
+ dependency: event.label,
169
+ attempt: event.attempt,
170
+ attempts: event.attempts,
171
+ delayMs: event.delayMs,
172
+ error: message
173
+ });
174
+ }
175
+ var MetricsRegistry = class {
176
+ counters = /* @__PURE__ */ new Map();
177
+ histograms = /* @__PURE__ */ new Map();
178
+ increment(name, labels, amount = 1) {
179
+ const key = metricKey(name, labels);
180
+ this.counters.set(key, (this.counters.get(key) ?? 0) + amount);
181
+ }
182
+ observe(name, buckets, value, labels) {
183
+ const key = metricKey(name, labels);
184
+ const histogram = this.histograms.get(key) ?? {
185
+ buckets,
186
+ counts: buckets.map(() => 0),
187
+ sum: 0,
188
+ count: 0,
189
+ labels
190
+ };
191
+ histogram.sum += value;
192
+ histogram.count += 1;
193
+ for (let index = 0; index < buckets.length; index += 1) {
194
+ if (value <= buckets[index]) {
195
+ histogram.counts[index] = (histogram.counts[index] ?? 0) + 1;
196
+ }
197
+ }
198
+ this.histograms.set(key, histogram);
199
+ }
200
+ toPrometheus(resourceLabels) {
201
+ const lines = [
202
+ "# HELP opengeni_http_requests_total Total HTTP requests handled by the OpenGeni API.",
203
+ "# TYPE opengeni_http_requests_total counter"
204
+ ];
205
+ for (const [key, value] of this.counters) {
206
+ const { name, labels } = parseMetricKey(key);
207
+ if (name.endsWith("_total")) {
208
+ lines.push(`${name}${formatLabels({ ...resourceLabels, ...labels })} ${value}`);
209
+ }
210
+ }
211
+ lines.push(
212
+ "# HELP opengeni_http_request_duration_seconds HTTP request duration in seconds.",
213
+ "# TYPE opengeni_http_request_duration_seconds histogram",
214
+ "# HELP opengeni_worker_activity_runs_total Total worker activity executions.",
215
+ "# TYPE opengeni_worker_activity_runs_total counter",
216
+ "# HELP opengeni_worker_activity_duration_seconds Worker activity duration in seconds.",
217
+ "# TYPE opengeni_worker_activity_duration_seconds histogram"
218
+ );
219
+ for (const [key, histogram] of this.histograms) {
220
+ const { name, labels } = parseMetricKey(key);
221
+ const baseLabels = { ...resourceLabels, ...labels };
222
+ for (let index = 0; index < histogram.buckets.length; index += 1) {
223
+ lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: String(histogram.buckets[index]) })} ${histogram.counts[index]}`);
224
+ }
225
+ lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: "+Inf" })} ${histogram.count}`);
226
+ lines.push(`${name}_sum${formatLabels(baseLabels)} ${histogram.sum}`);
227
+ lines.push(`${name}_count${formatLabels(baseLabels)} ${histogram.count}`);
228
+ }
229
+ return `${lines.join("\n")}
230
+ `;
231
+ }
232
+ };
233
+ function metricKey(name, labels) {
234
+ return JSON.stringify({ name, labels: Object.fromEntries(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b))) });
235
+ }
236
+ function parseMetricKey(key) {
237
+ return JSON.parse(key);
238
+ }
239
+ function formatLabels(labels) {
240
+ const entries = Object.entries(labels).filter(([, value]) => value.length > 0);
241
+ if (entries.length === 0) {
242
+ return "";
243
+ }
244
+ return `{${entries.map(([key, value]) => `${key}="${escapeMetricLabel(value)}"`).join(",")}}`;
245
+ }
246
+ function escapeMetricLabel(value) {
247
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
248
+ }
249
+ function cleanAttributes(attributes) {
250
+ return Object.fromEntries(Object.entries(attributes).filter(([, value]) => value !== void 0));
251
+ }
252
+ function errorToAttributes(error) {
253
+ return {
254
+ "error.type": error instanceof Error ? error.name : "Error",
255
+ "error.message": errorMessage(error)
256
+ };
257
+ }
258
+ function errorMessage(error) {
259
+ return error instanceof Error ? error.message : String(error);
260
+ }
261
+ function otlpAttributes(attributes) {
262
+ return Object.entries(cleanAttributes(attributes)).map(([key, value]) => ({
263
+ key,
264
+ value: otlpValue(value)
265
+ }));
266
+ }
267
+ function otlpValue(value) {
268
+ if (typeof value === "number") {
269
+ return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
270
+ }
271
+ if (typeof value === "boolean") {
272
+ return { boolValue: value };
273
+ }
274
+ return { stringValue: value === null ? "" : value };
275
+ }
276
+ function millisToNanos(ms) {
277
+ return String(BigInt(Math.round(ms)) * 1000000n);
278
+ }
279
+ function randomHex(bytes) {
280
+ const values = crypto.getRandomValues(new Uint8Array(bytes));
281
+ return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
282
+ }
283
+ function parseHeaders(value) {
284
+ if (!value.trim()) {
285
+ return {};
286
+ }
287
+ const entries = value.split(",").map((pair) => {
288
+ const separator = pair.indexOf("=");
289
+ if (separator === -1) {
290
+ return [pair.trim(), ""];
291
+ }
292
+ return [pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()];
293
+ }).filter(([key]) => key.length > 0);
294
+ return Object.fromEntries(entries);
295
+ }
296
+ async function defaultExporter(url, body, headers) {
297
+ const response = await fetch(url, {
298
+ method: "POST",
299
+ headers: {
300
+ "content-type": "application/json",
301
+ ...headers
302
+ },
303
+ body: JSON.stringify(body)
304
+ });
305
+ if (!response.ok) {
306
+ throw new Error(`OTLP endpoint returned HTTP ${response.status}`);
307
+ }
308
+ }
309
+ export {
310
+ Observability,
311
+ createObservability,
312
+ logStartupDependencyRetry,
313
+ parseHeaders
314
+ };
315
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["export type AttributeValue = string | number | boolean | null | undefined;\nexport type Attributes = Record<string, AttributeValue>;\n\nexport type ObservabilitySettings = {\n serviceName: string;\n environment: string;\n observabilityStructuredLogs: boolean;\n observabilityMetricsEnabled: boolean;\n observabilityOtlpEndpoint?: string | undefined;\n observabilityOtlpHeaders: string;\n};\n\nexport type ObservabilityOptions = {\n component: string;\n now?: () => number;\n exporter?: (url: string, body: unknown, headers: Record<string, string>) => Promise<void>;\n};\n\nexport type Span = {\n traceId: string;\n spanId: string;\n end: (input?: { attributes?: Attributes; error?: unknown }) => void;\n};\n\nconst histogramBuckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];\n\nexport function createObservability(settings: ObservabilitySettings, options: ObservabilityOptions): Observability {\n return new Observability(settings, options);\n}\n\nexport class Observability {\n private readonly metrics = new MetricsRegistry();\n private readonly now: () => number;\n private readonly exporter: (url: string, body: unknown, headers: Record<string, string>) => Promise<void>;\n private readonly resourceAttributes: Attributes;\n\n constructor(private readonly settings: ObservabilitySettings, private readonly options: ObservabilityOptions) {\n this.now = options.now ?? Date.now;\n this.exporter = options.exporter ?? defaultExporter;\n this.resourceAttributes = {\n \"service.name\": settings.serviceName,\n \"deployment.environment\": settings.environment,\n \"opengeni.component\": options.component,\n };\n }\n\n info(message: string, attributes: Attributes = {}): void {\n this.log(\"info\", message, attributes);\n }\n\n warn(message: string, attributes: Attributes = {}): void {\n this.log(\"warn\", message, attributes);\n }\n\n error(message: string, attributes: Attributes = {}): void {\n this.log(\"error\", message, attributes);\n }\n\n log(level: \"debug\" | \"info\" | \"warn\" | \"error\", message: string, attributes: Attributes = {}): void {\n if (!this.settings.observabilityStructuredLogs) {\n const line = attributes.error ? `${message}: ${String(attributes.error)}` : message;\n if (level === \"warn\") {\n console.warn(line);\n } else if (level === \"error\") {\n console.error(line);\n } else {\n console.log(line);\n }\n return;\n }\n const record = {\n timestamp: new Date(this.now()).toISOString(),\n level,\n message,\n service: this.settings.serviceName,\n environment: this.settings.environment,\n component: this.options.component,\n ...cleanAttributes(attributes),\n };\n const serialized = JSON.stringify(record);\n if (level === \"warn\") {\n console.warn(serialized);\n } else if (level === \"error\") {\n console.error(serialized);\n } else {\n console.log(serialized);\n }\n }\n\n startSpan(name: string, attributes: Attributes = {}): Span {\n const traceId = randomHex(16);\n const spanId = randomHex(8);\n const startMs = this.now();\n let ended = false;\n return {\n traceId,\n spanId,\n end: (input = {}) => {\n if (ended) {\n return;\n }\n ended = true;\n const errorAttributes = input.error ? errorToAttributes(input.error) : {};\n this.exportSpan({\n traceId,\n spanId,\n name,\n startMs,\n endMs: this.now(),\n attributes: {\n ...attributes,\n ...input.attributes,\n ...errorAttributes,\n },\n error: input.error,\n });\n },\n };\n }\n\n recordHttpRequest(input: { method: string; route: string; status: number; durationSeconds: number }): void {\n if (!this.settings.observabilityMetricsEnabled) {\n return;\n }\n const labels = {\n method: input.method,\n route: input.route,\n status: String(input.status),\n component: this.options.component,\n };\n this.metrics.increment(\"opengeni_http_requests_total\", labels);\n this.metrics.observe(\"opengeni_http_request_duration_seconds\", histogramBuckets, input.durationSeconds, {\n method: input.method,\n route: input.route,\n component: this.options.component,\n });\n }\n\n recordWorkerActivity(input: { activity: string; status: string; durationSeconds: number }): void {\n if (!this.settings.observabilityMetricsEnabled) {\n return;\n }\n const labels = {\n activity: input.activity,\n status: input.status,\n component: this.options.component,\n };\n this.metrics.increment(\"opengeni_worker_activity_runs_total\", labels);\n this.metrics.observe(\"opengeni_worker_activity_duration_seconds\", histogramBuckets, input.durationSeconds, {\n activity: input.activity,\n component: this.options.component,\n });\n }\n\n prometheusMetrics(): string {\n return this.metrics.toPrometheus({\n service: this.settings.serviceName,\n environment: this.settings.environment,\n component: this.options.component,\n });\n }\n\n private exportSpan(span: {\n traceId: string;\n spanId: string;\n name: string;\n startMs: number;\n endMs: number;\n attributes: Attributes;\n error?: unknown;\n }): void {\n if (!this.settings.observabilityOtlpEndpoint) {\n return;\n }\n const endpoint = `${this.settings.observabilityOtlpEndpoint.replace(/\\/$/, \"\")}/v1/traces`;\n const body = {\n resourceSpans: [{\n resource: {\n attributes: otlpAttributes(this.resourceAttributes),\n },\n scopeSpans: [{\n scope: {\n name: \"@opengeni/observability\",\n version: \"0.1.0\",\n },\n spans: [{\n traceId: span.traceId,\n spanId: span.spanId,\n name: span.name,\n kind: 1,\n startTimeUnixNano: millisToNanos(span.startMs),\n endTimeUnixNano: millisToNanos(span.endMs),\n attributes: otlpAttributes(span.attributes),\n status: span.error ? { code: 2, message: errorMessage(span.error) } : { code: 1 },\n }],\n }],\n }],\n };\n void this.exporter(endpoint, body, parseHeaders(this.settings.observabilityOtlpHeaders)).catch((error) => {\n this.warn(\"OTLP span export failed\", { error: errorMessage(error), endpoint });\n });\n }\n}\n\nexport type StartupDependencyRetryEvent = {\n label: string;\n attempt: number;\n attempts: number;\n delayMs: number;\n error: unknown;\n};\n\nexport function logStartupDependencyRetry(observability: Observability, event: StartupDependencyRetryEvent): void {\n const message = event.error instanceof Error ? event.error.message : String(event.error);\n observability.warn(\"Startup dependency connection failed; retrying\", {\n dependency: event.label,\n attempt: event.attempt,\n attempts: event.attempts,\n delayMs: event.delayMs,\n error: message,\n });\n}\n\nclass MetricsRegistry {\n private readonly counters = new Map<string, number>();\n private readonly histograms = new Map<string, { buckets: number[]; counts: number[]; sum: number; count: number; labels: Record<string, string> }>();\n\n increment(name: string, labels: Record<string, string>, amount = 1): void {\n const key = metricKey(name, labels);\n this.counters.set(key, (this.counters.get(key) ?? 0) + amount);\n }\n\n observe(name: string, buckets: number[], value: number, labels: Record<string, string>): void {\n const key = metricKey(name, labels);\n const histogram = this.histograms.get(key) ?? {\n buckets,\n counts: buckets.map(() => 0),\n sum: 0,\n count: 0,\n labels,\n };\n histogram.sum += value;\n histogram.count += 1;\n for (let index = 0; index < buckets.length; index += 1) {\n if (value <= buckets[index]!) {\n histogram.counts[index] = (histogram.counts[index] ?? 0) + 1;\n }\n }\n this.histograms.set(key, histogram);\n }\n\n toPrometheus(resourceLabels: Record<string, string>): string {\n const lines = [\n \"# HELP opengeni_http_requests_total Total HTTP requests handled by the OpenGeni API.\",\n \"# TYPE opengeni_http_requests_total counter\",\n ];\n for (const [key, value] of this.counters) {\n const { name, labels } = parseMetricKey(key);\n if (name.endsWith(\"_total\")) {\n lines.push(`${name}${formatLabels({ ...resourceLabels, ...labels })} ${value}`);\n }\n }\n lines.push(\n \"# HELP opengeni_http_request_duration_seconds HTTP request duration in seconds.\",\n \"# TYPE opengeni_http_request_duration_seconds histogram\",\n \"# HELP opengeni_worker_activity_runs_total Total worker activity executions.\",\n \"# TYPE opengeni_worker_activity_runs_total counter\",\n \"# HELP opengeni_worker_activity_duration_seconds Worker activity duration in seconds.\",\n \"# TYPE opengeni_worker_activity_duration_seconds histogram\",\n );\n for (const [key, histogram] of this.histograms) {\n const { name, labels } = parseMetricKey(key);\n const baseLabels = { ...resourceLabels, ...labels };\n for (let index = 0; index < histogram.buckets.length; index += 1) {\n lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: String(histogram.buckets[index]) })} ${histogram.counts[index]}`);\n }\n lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: \"+Inf\" })} ${histogram.count}`);\n lines.push(`${name}_sum${formatLabels(baseLabels)} ${histogram.sum}`);\n lines.push(`${name}_count${formatLabels(baseLabels)} ${histogram.count}`);\n }\n return `${lines.join(\"\\n\")}\\n`;\n }\n}\n\nfunction metricKey(name: string, labels: Record<string, string>): string {\n return JSON.stringify({ name, labels: Object.fromEntries(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b))) });\n}\n\nfunction parseMetricKey(key: string): { name: string; labels: Record<string, string> } {\n return JSON.parse(key) as { name: string; labels: Record<string, string> };\n}\n\nfunction formatLabels(labels: Record<string, string>): string {\n const entries = Object.entries(labels).filter(([, value]) => value.length > 0);\n if (entries.length === 0) {\n return \"\";\n }\n return `{${entries.map(([key, value]) => `${key}=\"${escapeMetricLabel(value)}\"`).join(\",\")}}`;\n}\n\nfunction escapeMetricLabel(value: string): string {\n return value.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, \"\\\\\\\"\").replace(/\\n/g, \"\\\\n\");\n}\n\nfunction cleanAttributes(attributes: Attributes): Record<string, string | number | boolean | null> {\n return Object.fromEntries(Object.entries(attributes).filter(([, value]) => value !== undefined)) as Record<string, string | number | boolean | null>;\n}\n\nfunction errorToAttributes(error: unknown): Attributes {\n return {\n \"error.type\": error instanceof Error ? error.name : \"Error\",\n \"error.message\": errorMessage(error),\n };\n}\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nfunction otlpAttributes(attributes: Attributes): Array<{ key: string; value: Record<string, string | number | boolean> }> {\n return Object.entries(cleanAttributes(attributes)).map(([key, value]) => ({\n key,\n value: otlpValue(value),\n }));\n}\n\nfunction otlpValue(value: string | number | boolean | null): Record<string, string | number | boolean> {\n if (typeof value === \"number\") {\n return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };\n }\n if (typeof value === \"boolean\") {\n return { boolValue: value };\n }\n return { stringValue: value === null ? \"\" : value };\n}\n\nfunction millisToNanos(ms: number): string {\n return String(BigInt(Math.round(ms)) * 1_000_000n);\n}\n\nfunction randomHex(bytes: number): string {\n const values = crypto.getRandomValues(new Uint8Array(bytes));\n return Array.from(values, (value) => value.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport function parseHeaders(value: string): Record<string, string> {\n if (!value.trim()) {\n return {};\n }\n const entries: Array<[string, string]> = value.split(\",\").map((pair): [string, string] => {\n const separator = pair.indexOf(\"=\");\n if (separator === -1) {\n return [pair.trim(), \"\"];\n }\n return [pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()];\n }).filter(([key]) => key.length > 0);\n return Object.fromEntries(entries);\n}\n\nasync function defaultExporter(url: string, body: unknown, headers: Record<string, string>): Promise<void> {\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n ...headers,\n },\n body: JSON.stringify(body),\n });\n if (!response.ok) {\n throw new Error(`OTLP endpoint returned HTTP ${response.status}`);\n }\n}\n"],"mappings":";AAwBA,IAAM,mBAAmB,CAAC,MAAO,MAAM,OAAO,MAAM,KAAK,MAAM,KAAK,GAAG,KAAK,GAAG,EAAE;AAE1E,SAAS,oBAAoB,UAAiC,SAA8C;AACjH,SAAO,IAAI,cAAc,UAAU,OAAO;AAC5C;AAEO,IAAM,gBAAN,MAAoB;AAAA,EAMzB,YAA6B,UAAkD,SAA+B;AAAjF;AAAkD;AAC7E,SAAK,MAAM,QAAQ,OAAO,KAAK;AAC/B,SAAK,WAAW,QAAQ,YAAY;AACpC,SAAK,qBAAqB;AAAA,MACxB,gBAAgB,SAAS;AAAA,MACzB,0BAA0B,SAAS;AAAA,MACnC,sBAAsB,QAAQ;AAAA,IAChC;AAAA,EACF;AAAA,EAR6B;AAAA,EAAkD;AAAA,EAL9D,UAAU,IAAI,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EAYjB,KAAK,SAAiB,aAAyB,CAAC,GAAS;AACvD,SAAK,IAAI,QAAQ,SAAS,UAAU;AAAA,EACtC;AAAA,EAEA,KAAK,SAAiB,aAAyB,CAAC,GAAS;AACvD,SAAK,IAAI,QAAQ,SAAS,UAAU;AAAA,EACtC;AAAA,EAEA,MAAM,SAAiB,aAAyB,CAAC,GAAS;AACxD,SAAK,IAAI,SAAS,SAAS,UAAU;AAAA,EACvC;AAAA,EAEA,IAAI,OAA4C,SAAiB,aAAyB,CAAC,GAAS;AAClG,QAAI,CAAC,KAAK,SAAS,6BAA6B;AAC9C,YAAM,OAAO,WAAW,QAAQ,GAAG,OAAO,KAAK,OAAO,WAAW,KAAK,CAAC,KAAK;AAC5E,UAAI,UAAU,QAAQ;AACpB,gBAAQ,KAAK,IAAI;AAAA,MACnB,WAAW,UAAU,SAAS;AAC5B,gBAAQ,MAAM,IAAI;AAAA,MACpB,OAAO;AACL,gBAAQ,IAAI,IAAI;AAAA,MAClB;AACA;AAAA,IACF;AACA,UAAM,SAAS;AAAA,MACb,WAAW,IAAI,KAAK,KAAK,IAAI,CAAC,EAAE,YAAY;AAAA,MAC5C;AAAA,MACA;AAAA,MACA,SAAS,KAAK,SAAS;AAAA,MACvB,aAAa,KAAK,SAAS;AAAA,MAC3B,WAAW,KAAK,QAAQ;AAAA,MACxB,GAAG,gBAAgB,UAAU;AAAA,IAC/B;AACA,UAAM,aAAa,KAAK,UAAU,MAAM;AACxC,QAAI,UAAU,QAAQ;AACpB,cAAQ,KAAK,UAAU;AAAA,IACzB,WAAW,UAAU,SAAS;AAC5B,cAAQ,MAAM,UAAU;AAAA,IAC1B,OAAO;AACL,cAAQ,IAAI,UAAU;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,UAAU,MAAc,aAAyB,CAAC,GAAS;AACzD,UAAM,UAAU,UAAU,EAAE;AAC5B,UAAM,SAAS,UAAU,CAAC;AAC1B,UAAM,UAAU,KAAK,IAAI;AACzB,QAAI,QAAQ;AACZ,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,KAAK,CAAC,QAAQ,CAAC,MAAM;AACnB,YAAI,OAAO;AACT;AAAA,QACF;AACA,gBAAQ;AACR,cAAM,kBAAkB,MAAM,QAAQ,kBAAkB,MAAM,KAAK,IAAI,CAAC;AACxE,aAAK,WAAW;AAAA,UACd;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA,UACA,OAAO,KAAK,IAAI;AAAA,UAChB,YAAY;AAAA,YACV,GAAG;AAAA,YACH,GAAG,MAAM;AAAA,YACT,GAAG;AAAA,UACL;AAAA,UACA,OAAO,MAAM;AAAA,QACf,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF;AAAA,EAEA,kBAAkB,OAAyF;AACzG,QAAI,CAAC,KAAK,SAAS,6BAA6B;AAC9C;AAAA,IACF;AACA,UAAM,SAAS;AAAA,MACb,QAAQ,MAAM;AAAA,MACd,OAAO,MAAM;AAAA,MACb,QAAQ,OAAO,MAAM,MAAM;AAAA,MAC3B,WAAW,KAAK,QAAQ;AAAA,IAC1B;AACA,SAAK,QAAQ,UAAU,gCAAgC,MAAM;AAC7D,SAAK,QAAQ,QAAQ,0CAA0C,kBAAkB,MAAM,iBAAiB;AAAA,MACtG,QAAQ,MAAM;AAAA,MACd,OAAO,MAAM;AAAA,MACb,WAAW,KAAK,QAAQ;AAAA,IAC1B,CAAC;AAAA,EACH;AAAA,EAEA,qBAAqB,OAA4E;AAC/F,QAAI,CAAC,KAAK,SAAS,6BAA6B;AAC9C;AAAA,IACF;AACA,UAAM,SAAS;AAAA,MACb,UAAU,MAAM;AAAA,MAChB,QAAQ,MAAM;AAAA,MACd,WAAW,KAAK,QAAQ;AAAA,IAC1B;AACA,SAAK,QAAQ,UAAU,uCAAuC,MAAM;AACpE,SAAK,QAAQ,QAAQ,6CAA6C,kBAAkB,MAAM,iBAAiB;AAAA,MACzG,UAAU,MAAM;AAAA,MAChB,WAAW,KAAK,QAAQ;AAAA,IAC1B,CAAC;AAAA,EACH;AAAA,EAEA,oBAA4B;AAC1B,WAAO,KAAK,QAAQ,aAAa;AAAA,MAC/B,SAAS,KAAK,SAAS;AAAA,MACvB,aAAa,KAAK,SAAS;AAAA,MAC3B,WAAW,KAAK,QAAQ;AAAA,IAC1B,CAAC;AAAA,EACH;AAAA,EAEQ,WAAW,MAQV;AACP,QAAI,CAAC,KAAK,SAAS,2BAA2B;AAC5C;AAAA,IACF;AACA,UAAM,WAAW,GAAG,KAAK,SAAS,0BAA0B,QAAQ,OAAO,EAAE,CAAC;AAC9E,UAAM,OAAO;AAAA,MACX,eAAe,CAAC;AAAA,QACd,UAAU;AAAA,UACR,YAAY,eAAe,KAAK,kBAAkB;AAAA,QACpD;AAAA,QACA,YAAY,CAAC;AAAA,UACX,OAAO;AAAA,YACL,MAAM;AAAA,YACN,SAAS;AAAA,UACX;AAAA,UACA,OAAO,CAAC;AAAA,YACN,SAAS,KAAK;AAAA,YACd,QAAQ,KAAK;AAAA,YACb,MAAM,KAAK;AAAA,YACX,MAAM;AAAA,YACN,mBAAmB,cAAc,KAAK,OAAO;AAAA,YAC7C,iBAAiB,cAAc,KAAK,KAAK;AAAA,YACzC,YAAY,eAAe,KAAK,UAAU;AAAA,YAC1C,QAAQ,KAAK,QAAQ,EAAE,MAAM,GAAG,SAAS,aAAa,KAAK,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE;AAAA,UAClF,CAAC;AAAA,QACH,CAAC;AAAA,MACH,CAAC;AAAA,IACH;AACA,SAAK,KAAK,SAAS,UAAU,MAAM,aAAa,KAAK,SAAS,wBAAwB,CAAC,EAAE,MAAM,CAAC,UAAU;AACxG,WAAK,KAAK,2BAA2B,EAAE,OAAO,aAAa,KAAK,GAAG,SAAS,CAAC;AAAA,IAC/E,CAAC;AAAA,EACH;AACF;AAUO,SAAS,0BAA0B,eAA8B,OAA0C;AAChH,QAAM,UAAU,MAAM,iBAAiB,QAAQ,MAAM,MAAM,UAAU,OAAO,MAAM,KAAK;AACvF,gBAAc,KAAK,kDAAkD;AAAA,IACnE,YAAY,MAAM;AAAA,IAClB,SAAS,MAAM;AAAA,IACf,UAAU,MAAM;AAAA,IAChB,SAAS,MAAM;AAAA,IACf,OAAO;AAAA,EACT,CAAC;AACH;AAEA,IAAM,kBAAN,MAAsB;AAAA,EACH,WAAW,oBAAI,IAAoB;AAAA,EACnC,aAAa,oBAAI,IAAiH;AAAA,EAEnJ,UAAU,MAAc,QAAgC,SAAS,GAAS;AACxE,UAAM,MAAM,UAAU,MAAM,MAAM;AAClC,SAAK,SAAS,IAAI,MAAM,KAAK,SAAS,IAAI,GAAG,KAAK,KAAK,MAAM;AAAA,EAC/D;AAAA,EAEA,QAAQ,MAAc,SAAmB,OAAe,QAAsC;AAC5F,UAAM,MAAM,UAAU,MAAM,MAAM;AAClC,UAAM,YAAY,KAAK,WAAW,IAAI,GAAG,KAAK;AAAA,MAC5C;AAAA,MACA,QAAQ,QAAQ,IAAI,MAAM,CAAC;AAAA,MAC3B,KAAK;AAAA,MACL,OAAO;AAAA,MACP;AAAA,IACF;AACA,cAAU,OAAO;AACjB,cAAU,SAAS;AACnB,aAAS,QAAQ,GAAG,QAAQ,QAAQ,QAAQ,SAAS,GAAG;AACtD,UAAI,SAAS,QAAQ,KAAK,GAAI;AAC5B,kBAAU,OAAO,KAAK,KAAK,UAAU,OAAO,KAAK,KAAK,KAAK;AAAA,MAC7D;AAAA,IACF;AACA,SAAK,WAAW,IAAI,KAAK,SAAS;AAAA,EACpC;AAAA,EAEA,aAAa,gBAAgD;AAC3D,UAAM,QAAQ;AAAA,MACZ;AAAA,MACA;AAAA,IACF;AACA,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,UAAU;AACxC,YAAM,EAAE,MAAM,OAAO,IAAI,eAAe,GAAG;AAC3C,UAAI,KAAK,SAAS,QAAQ,GAAG;AAC3B,cAAM,KAAK,GAAG,IAAI,GAAG,aAAa,EAAE,GAAG,gBAAgB,GAAG,OAAO,CAAC,CAAC,IAAI,KAAK,EAAE;AAAA,MAChF;AAAA,IACF;AACA,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,eAAW,CAAC,KAAK,SAAS,KAAK,KAAK,YAAY;AAC9C,YAAM,EAAE,MAAM,OAAO,IAAI,eAAe,GAAG;AAC3C,YAAM,aAAa,EAAE,GAAG,gBAAgB,GAAG,OAAO;AAClD,eAAS,QAAQ,GAAG,QAAQ,UAAU,QAAQ,QAAQ,SAAS,GAAG;AAChE,cAAM,KAAK,GAAG,IAAI,UAAU,aAAa,EAAE,GAAG,YAAY,IAAI,OAAO,UAAU,QAAQ,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,UAAU,OAAO,KAAK,CAAC,EAAE;AAAA,MAChI;AACA,YAAM,KAAK,GAAG,IAAI,UAAU,aAAa,EAAE,GAAG,YAAY,IAAI,OAAO,CAAC,CAAC,IAAI,UAAU,KAAK,EAAE;AAC5F,YAAM,KAAK,GAAG,IAAI,OAAO,aAAa,UAAU,CAAC,IAAI,UAAU,GAAG,EAAE;AACpE,YAAM,KAAK,GAAG,IAAI,SAAS,aAAa,UAAU,CAAC,IAAI,UAAU,KAAK,EAAE;AAAA,IAC1E;AACA,WAAO,GAAG,MAAM,KAAK,IAAI,CAAC;AAAA;AAAA,EAC5B;AACF;AAEA,SAAS,UAAU,MAAc,QAAwC;AACvE,SAAO,KAAK,UAAU,EAAE,MAAM,QAAQ,OAAO,YAAY,OAAO,QAAQ,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC;AAC3H;AAEA,SAAS,eAAe,KAA+D;AACrF,SAAO,KAAK,MAAM,GAAG;AACvB;AAEA,SAAS,aAAa,QAAwC;AAC5D,QAAM,UAAU,OAAO,QAAQ,MAAM,EAAE,OAAO,CAAC,CAAC,EAAE,KAAK,MAAM,MAAM,SAAS,CAAC;AAC7E,MAAI,QAAQ,WAAW,GAAG;AACxB,WAAO;AAAA,EACT;AACA,SAAO,IAAI,QAAQ,IAAI,CAAC,CAAC,KAAK,KAAK,MAAM,GAAG,GAAG,KAAK,kBAAkB,KAAK,CAAC,GAAG,EAAE,KAAK,GAAG,CAAC;AAC5F;AAEA,SAAS,kBAAkB,OAAuB;AAChD,SAAO,MAAM,QAAQ,OAAO,MAAM,EAAE,QAAQ,MAAM,KAAM,EAAE,QAAQ,OAAO,KAAK;AAChF;AAEA,SAAS,gBAAgB,YAA0E;AACjG,SAAO,OAAO,YAAY,OAAO,QAAQ,UAAU,EAAE,OAAO,CAAC,CAAC,EAAE,KAAK,MAAM,UAAU,MAAS,CAAC;AACjG;AAEA,SAAS,kBAAkB,OAA4B;AACrD,SAAO;AAAA,IACL,cAAc,iBAAiB,QAAQ,MAAM,OAAO;AAAA,IACpD,iBAAiB,aAAa,KAAK;AAAA,EACrC;AACF;AAEA,SAAS,aAAa,OAAwB;AAC5C,SAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D;AAEA,SAAS,eAAe,YAAkG;AACxH,SAAO,OAAO,QAAQ,gBAAgB,UAAU,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,OAAO;AAAA,IACxE;AAAA,IACA,OAAO,UAAU,KAAK;AAAA,EACxB,EAAE;AACJ;AAEA,SAAS,UAAU,OAAoF;AACrG,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO,OAAO,UAAU,KAAK,IAAI,EAAE,UAAU,MAAM,IAAI,EAAE,aAAa,MAAM;AAAA,EAC9E;AACA,MAAI,OAAO,UAAU,WAAW;AAC9B,WAAO,EAAE,WAAW,MAAM;AAAA,EAC5B;AACA,SAAO,EAAE,aAAa,UAAU,OAAO,KAAK,MAAM;AACpD;AAEA,SAAS,cAAc,IAAoB;AACzC,SAAO,OAAO,OAAO,KAAK,MAAM,EAAE,CAAC,IAAI,QAAU;AACnD;AAEA,SAAS,UAAU,OAAuB;AACxC,QAAM,SAAS,OAAO,gBAAgB,IAAI,WAAW,KAAK,CAAC;AAC3D,SAAO,MAAM,KAAK,QAAQ,CAAC,UAAU,MAAM,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AACnF;AAEO,SAAS,aAAa,OAAuC;AAClE,MAAI,CAAC,MAAM,KAAK,GAAG;AACjB,WAAO,CAAC;AAAA,EACV;AACA,QAAM,UAAmC,MAAM,MAAM,GAAG,EAAE,IAAI,CAAC,SAA2B;AACxF,UAAM,YAAY,KAAK,QAAQ,GAAG;AAClC,QAAI,cAAc,IAAI;AACpB,aAAO,CAAC,KAAK,KAAK,GAAG,EAAE;AAAA,IACzB;AACA,WAAO,CAAC,KAAK,MAAM,GAAG,SAAS,EAAE,KAAK,GAAG,KAAK,MAAM,YAAY,CAAC,EAAE,KAAK,CAAC;AAAA,EAC3E,CAAC,EAAE,OAAO,CAAC,CAAC,GAAG,MAAM,IAAI,SAAS,CAAC;AACnC,SAAO,OAAO,YAAY,OAAO;AACnC;AAEA,eAAe,gBAAgB,KAAa,MAAe,SAAgD;AACzG,QAAM,WAAW,MAAM,MAAM,KAAK;AAAA,IAChC,QAAQ;AAAA,IACR,SAAS;AAAA,MACP,gBAAgB;AAAA,MAChB,GAAG;AAAA,IACL;AAAA,IACA,MAAM,KAAK,UAAU,IAAI;AAAA,EAC3B,CAAC;AACD,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,IAAI,MAAM,+BAA+B,SAAS,MAAM,EAAE;AAAA,EAClE;AACF;","names":[]}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@opengeni/observability",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "provenance": true
21
+ },
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "devDependencies": {
27
+ "tsup": "^8.5.0",
28
+ "typescript": "^6.0.3"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
33
+ "directory": "packages/observability"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,372 @@
1
+ export type AttributeValue = string | number | boolean | null | undefined;
2
+ export type Attributes = Record<string, AttributeValue>;
3
+
4
+ export type ObservabilitySettings = {
5
+ serviceName: string;
6
+ environment: string;
7
+ observabilityStructuredLogs: boolean;
8
+ observabilityMetricsEnabled: boolean;
9
+ observabilityOtlpEndpoint?: string | undefined;
10
+ observabilityOtlpHeaders: string;
11
+ };
12
+
13
+ export type ObservabilityOptions = {
14
+ component: string;
15
+ now?: () => number;
16
+ exporter?: (url: string, body: unknown, headers: Record<string, string>) => Promise<void>;
17
+ };
18
+
19
+ export type Span = {
20
+ traceId: string;
21
+ spanId: string;
22
+ end: (input?: { attributes?: Attributes; error?: unknown }) => void;
23
+ };
24
+
25
+ const histogramBuckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
26
+
27
+ export function createObservability(settings: ObservabilitySettings, options: ObservabilityOptions): Observability {
28
+ return new Observability(settings, options);
29
+ }
30
+
31
+ export class Observability {
32
+ private readonly metrics = new MetricsRegistry();
33
+ private readonly now: () => number;
34
+ private readonly exporter: (url: string, body: unknown, headers: Record<string, string>) => Promise<void>;
35
+ private readonly resourceAttributes: Attributes;
36
+
37
+ constructor(private readonly settings: ObservabilitySettings, private readonly options: ObservabilityOptions) {
38
+ this.now = options.now ?? Date.now;
39
+ this.exporter = options.exporter ?? defaultExporter;
40
+ this.resourceAttributes = {
41
+ "service.name": settings.serviceName,
42
+ "deployment.environment": settings.environment,
43
+ "opengeni.component": options.component,
44
+ };
45
+ }
46
+
47
+ info(message: string, attributes: Attributes = {}): void {
48
+ this.log("info", message, attributes);
49
+ }
50
+
51
+ warn(message: string, attributes: Attributes = {}): void {
52
+ this.log("warn", message, attributes);
53
+ }
54
+
55
+ error(message: string, attributes: Attributes = {}): void {
56
+ this.log("error", message, attributes);
57
+ }
58
+
59
+ log(level: "debug" | "info" | "warn" | "error", message: string, attributes: Attributes = {}): void {
60
+ if (!this.settings.observabilityStructuredLogs) {
61
+ const line = attributes.error ? `${message}: ${String(attributes.error)}` : message;
62
+ if (level === "warn") {
63
+ console.warn(line);
64
+ } else if (level === "error") {
65
+ console.error(line);
66
+ } else {
67
+ console.log(line);
68
+ }
69
+ return;
70
+ }
71
+ const record = {
72
+ timestamp: new Date(this.now()).toISOString(),
73
+ level,
74
+ message,
75
+ service: this.settings.serviceName,
76
+ environment: this.settings.environment,
77
+ component: this.options.component,
78
+ ...cleanAttributes(attributes),
79
+ };
80
+ const serialized = JSON.stringify(record);
81
+ if (level === "warn") {
82
+ console.warn(serialized);
83
+ } else if (level === "error") {
84
+ console.error(serialized);
85
+ } else {
86
+ console.log(serialized);
87
+ }
88
+ }
89
+
90
+ startSpan(name: string, attributes: Attributes = {}): Span {
91
+ const traceId = randomHex(16);
92
+ const spanId = randomHex(8);
93
+ const startMs = this.now();
94
+ let ended = false;
95
+ return {
96
+ traceId,
97
+ spanId,
98
+ end: (input = {}) => {
99
+ if (ended) {
100
+ return;
101
+ }
102
+ ended = true;
103
+ const errorAttributes = input.error ? errorToAttributes(input.error) : {};
104
+ this.exportSpan({
105
+ traceId,
106
+ spanId,
107
+ name,
108
+ startMs,
109
+ endMs: this.now(),
110
+ attributes: {
111
+ ...attributes,
112
+ ...input.attributes,
113
+ ...errorAttributes,
114
+ },
115
+ error: input.error,
116
+ });
117
+ },
118
+ };
119
+ }
120
+
121
+ recordHttpRequest(input: { method: string; route: string; status: number; durationSeconds: number }): void {
122
+ if (!this.settings.observabilityMetricsEnabled) {
123
+ return;
124
+ }
125
+ const labels = {
126
+ method: input.method,
127
+ route: input.route,
128
+ status: String(input.status),
129
+ component: this.options.component,
130
+ };
131
+ this.metrics.increment("opengeni_http_requests_total", labels);
132
+ this.metrics.observe("opengeni_http_request_duration_seconds", histogramBuckets, input.durationSeconds, {
133
+ method: input.method,
134
+ route: input.route,
135
+ component: this.options.component,
136
+ });
137
+ }
138
+
139
+ recordWorkerActivity(input: { activity: string; status: string; durationSeconds: number }): void {
140
+ if (!this.settings.observabilityMetricsEnabled) {
141
+ return;
142
+ }
143
+ const labels = {
144
+ activity: input.activity,
145
+ status: input.status,
146
+ component: this.options.component,
147
+ };
148
+ this.metrics.increment("opengeni_worker_activity_runs_total", labels);
149
+ this.metrics.observe("opengeni_worker_activity_duration_seconds", histogramBuckets, input.durationSeconds, {
150
+ activity: input.activity,
151
+ component: this.options.component,
152
+ });
153
+ }
154
+
155
+ prometheusMetrics(): string {
156
+ return this.metrics.toPrometheus({
157
+ service: this.settings.serviceName,
158
+ environment: this.settings.environment,
159
+ component: this.options.component,
160
+ });
161
+ }
162
+
163
+ private exportSpan(span: {
164
+ traceId: string;
165
+ spanId: string;
166
+ name: string;
167
+ startMs: number;
168
+ endMs: number;
169
+ attributes: Attributes;
170
+ error?: unknown;
171
+ }): void {
172
+ if (!this.settings.observabilityOtlpEndpoint) {
173
+ return;
174
+ }
175
+ const endpoint = `${this.settings.observabilityOtlpEndpoint.replace(/\/$/, "")}/v1/traces`;
176
+ const body = {
177
+ resourceSpans: [{
178
+ resource: {
179
+ attributes: otlpAttributes(this.resourceAttributes),
180
+ },
181
+ scopeSpans: [{
182
+ scope: {
183
+ name: "@opengeni/observability",
184
+ version: "0.1.0",
185
+ },
186
+ spans: [{
187
+ traceId: span.traceId,
188
+ spanId: span.spanId,
189
+ name: span.name,
190
+ kind: 1,
191
+ startTimeUnixNano: millisToNanos(span.startMs),
192
+ endTimeUnixNano: millisToNanos(span.endMs),
193
+ attributes: otlpAttributes(span.attributes),
194
+ status: span.error ? { code: 2, message: errorMessage(span.error) } : { code: 1 },
195
+ }],
196
+ }],
197
+ }],
198
+ };
199
+ void this.exporter(endpoint, body, parseHeaders(this.settings.observabilityOtlpHeaders)).catch((error) => {
200
+ this.warn("OTLP span export failed", { error: errorMessage(error), endpoint });
201
+ });
202
+ }
203
+ }
204
+
205
+ export type StartupDependencyRetryEvent = {
206
+ label: string;
207
+ attempt: number;
208
+ attempts: number;
209
+ delayMs: number;
210
+ error: unknown;
211
+ };
212
+
213
+ export function logStartupDependencyRetry(observability: Observability, event: StartupDependencyRetryEvent): void {
214
+ const message = event.error instanceof Error ? event.error.message : String(event.error);
215
+ observability.warn("Startup dependency connection failed; retrying", {
216
+ dependency: event.label,
217
+ attempt: event.attempt,
218
+ attempts: event.attempts,
219
+ delayMs: event.delayMs,
220
+ error: message,
221
+ });
222
+ }
223
+
224
+ class MetricsRegistry {
225
+ private readonly counters = new Map<string, number>();
226
+ private readonly histograms = new Map<string, { buckets: number[]; counts: number[]; sum: number; count: number; labels: Record<string, string> }>();
227
+
228
+ increment(name: string, labels: Record<string, string>, amount = 1): void {
229
+ const key = metricKey(name, labels);
230
+ this.counters.set(key, (this.counters.get(key) ?? 0) + amount);
231
+ }
232
+
233
+ observe(name: string, buckets: number[], value: number, labels: Record<string, string>): void {
234
+ const key = metricKey(name, labels);
235
+ const histogram = this.histograms.get(key) ?? {
236
+ buckets,
237
+ counts: buckets.map(() => 0),
238
+ sum: 0,
239
+ count: 0,
240
+ labels,
241
+ };
242
+ histogram.sum += value;
243
+ histogram.count += 1;
244
+ for (let index = 0; index < buckets.length; index += 1) {
245
+ if (value <= buckets[index]!) {
246
+ histogram.counts[index] = (histogram.counts[index] ?? 0) + 1;
247
+ }
248
+ }
249
+ this.histograms.set(key, histogram);
250
+ }
251
+
252
+ toPrometheus(resourceLabels: Record<string, string>): string {
253
+ const lines = [
254
+ "# HELP opengeni_http_requests_total Total HTTP requests handled by the OpenGeni API.",
255
+ "# TYPE opengeni_http_requests_total counter",
256
+ ];
257
+ for (const [key, value] of this.counters) {
258
+ const { name, labels } = parseMetricKey(key);
259
+ if (name.endsWith("_total")) {
260
+ lines.push(`${name}${formatLabels({ ...resourceLabels, ...labels })} ${value}`);
261
+ }
262
+ }
263
+ lines.push(
264
+ "# HELP opengeni_http_request_duration_seconds HTTP request duration in seconds.",
265
+ "# TYPE opengeni_http_request_duration_seconds histogram",
266
+ "# HELP opengeni_worker_activity_runs_total Total worker activity executions.",
267
+ "# TYPE opengeni_worker_activity_runs_total counter",
268
+ "# HELP opengeni_worker_activity_duration_seconds Worker activity duration in seconds.",
269
+ "# TYPE opengeni_worker_activity_duration_seconds histogram",
270
+ );
271
+ for (const [key, histogram] of this.histograms) {
272
+ const { name, labels } = parseMetricKey(key);
273
+ const baseLabels = { ...resourceLabels, ...labels };
274
+ for (let index = 0; index < histogram.buckets.length; index += 1) {
275
+ lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: String(histogram.buckets[index]) })} ${histogram.counts[index]}`);
276
+ }
277
+ lines.push(`${name}_bucket${formatLabels({ ...baseLabels, le: "+Inf" })} ${histogram.count}`);
278
+ lines.push(`${name}_sum${formatLabels(baseLabels)} ${histogram.sum}`);
279
+ lines.push(`${name}_count${formatLabels(baseLabels)} ${histogram.count}`);
280
+ }
281
+ return `${lines.join("\n")}\n`;
282
+ }
283
+ }
284
+
285
+ function metricKey(name: string, labels: Record<string, string>): string {
286
+ return JSON.stringify({ name, labels: Object.fromEntries(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b))) });
287
+ }
288
+
289
+ function parseMetricKey(key: string): { name: string; labels: Record<string, string> } {
290
+ return JSON.parse(key) as { name: string; labels: Record<string, string> };
291
+ }
292
+
293
+ function formatLabels(labels: Record<string, string>): string {
294
+ const entries = Object.entries(labels).filter(([, value]) => value.length > 0);
295
+ if (entries.length === 0) {
296
+ return "";
297
+ }
298
+ return `{${entries.map(([key, value]) => `${key}="${escapeMetricLabel(value)}"`).join(",")}}`;
299
+ }
300
+
301
+ function escapeMetricLabel(value: string): string {
302
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n");
303
+ }
304
+
305
+ function cleanAttributes(attributes: Attributes): Record<string, string | number | boolean | null> {
306
+ return Object.fromEntries(Object.entries(attributes).filter(([, value]) => value !== undefined)) as Record<string, string | number | boolean | null>;
307
+ }
308
+
309
+ function errorToAttributes(error: unknown): Attributes {
310
+ return {
311
+ "error.type": error instanceof Error ? error.name : "Error",
312
+ "error.message": errorMessage(error),
313
+ };
314
+ }
315
+
316
+ function errorMessage(error: unknown): string {
317
+ return error instanceof Error ? error.message : String(error);
318
+ }
319
+
320
+ function otlpAttributes(attributes: Attributes): Array<{ key: string; value: Record<string, string | number | boolean> }> {
321
+ return Object.entries(cleanAttributes(attributes)).map(([key, value]) => ({
322
+ key,
323
+ value: otlpValue(value),
324
+ }));
325
+ }
326
+
327
+ function otlpValue(value: string | number | boolean | null): Record<string, string | number | boolean> {
328
+ if (typeof value === "number") {
329
+ return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
330
+ }
331
+ if (typeof value === "boolean") {
332
+ return { boolValue: value };
333
+ }
334
+ return { stringValue: value === null ? "" : value };
335
+ }
336
+
337
+ function millisToNanos(ms: number): string {
338
+ return String(BigInt(Math.round(ms)) * 1_000_000n);
339
+ }
340
+
341
+ function randomHex(bytes: number): string {
342
+ const values = crypto.getRandomValues(new Uint8Array(bytes));
343
+ return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
344
+ }
345
+
346
+ export function parseHeaders(value: string): Record<string, string> {
347
+ if (!value.trim()) {
348
+ return {};
349
+ }
350
+ const entries: Array<[string, string]> = value.split(",").map((pair): [string, string] => {
351
+ const separator = pair.indexOf("=");
352
+ if (separator === -1) {
353
+ return [pair.trim(), ""];
354
+ }
355
+ return [pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()];
356
+ }).filter(([key]) => key.length > 0);
357
+ return Object.fromEntries(entries);
358
+ }
359
+
360
+ async function defaultExporter(url: string, body: unknown, headers: Record<string, string>): Promise<void> {
361
+ const response = await fetch(url, {
362
+ method: "POST",
363
+ headers: {
364
+ "content-type": "application/json",
365
+ ...headers,
366
+ },
367
+ body: JSON.stringify(body),
368
+ });
369
+ if (!response.ok) {
370
+ throw new Error(`OTLP endpoint returned HTTP ${response.status}`);
371
+ }
372
+ }